/* * Copyright (c) 2003-onwards Shaven Puppy Ltd * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are * met: * * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * * Neither the name of 'Shaven Puppy' nor the names of its contributors * may be used to endorse or promote products derived from this software * without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ package com.shavenpuppy.jglib.util; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.StringTokenizer; import net.java.dev.eval.Expression; import org.lwjgl.util.Color; import org.lwjgl.util.Dimension; import org.lwjgl.util.Point; import org.lwjgl.util.Rectangle; import org.lwjgl.util.vector.Vector3f; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.w3c.dom.Text; import com.shavenpuppy.jglib.IResource; import com.shavenpuppy.jglib.Point2f; import com.shavenpuppy.jglib.Resource; import com.shavenpuppy.jglib.Resources; import com.shavenpuppy.jglib.resources.ColorParser; import com.shavenpuppy.jglib.resources.DimensionParser; import com.shavenpuppy.jglib.resources.Point2fParser; import com.shavenpuppy.jglib.resources.PointParser; import com.shavenpuppy.jglib.resources.RectangleParser; import com.shavenpuppy.jglib.resources.TextWrapper; import com.shavenpuppy.jglib.resources.Vector3fParser; /** * Some simple XML utilities * @author cas */ public final class XMLUtil { /** A stash of variables which we automatically decode */ private static final Map<String, String> vars = new HashMap<String, String>(); /** * No constructor; this is a static class */ private XMLUtil() {} /** * Add a variable * @param key The key * @param value the value */ public static void putVar(String key, String value) { vars.put(key, value); } /** * Decode a variable. Variables are in the form $varname. If the incoming * expression is not a variable or is not recognised it is simply returned * verbatim. * @param in The incoming attribute * @return String * @throws Exception */ private static String decode(String in) throws Exception { if (in != null && in.length() > 1 && in.charAt(0) == '$') { String key = in.substring(1); if (key.charAt(0) == '#') { // It's a class name and reflection job in the form $$full.class.name.member int lastIdx = key.lastIndexOf('.'); String member = key.substring(lastIdx + 1); String className = key.substring(1, lastIdx); Class<?> clazz = Class.forName(className); Field field = clazz.getDeclaredField(member); field.setAccessible(true); return String.valueOf(field.get(null)); } else { String val = vars.get(key); if (val == null) { throw new Exception("Unknown variable "+in); } else { return val; } } } else { return in; } } /** * Parse an expression made up of variables and simple operators. Incoming strings beginning with the = * character are parsed as simple expressions from left to right. * The expressions understood are +, -, *, / for numeric values and & for string values (concatenator) * @param in The incoming expression * @return the final resolved value * @throws Exception */ public static String parse(String in) throws Exception { if (in.length() < 2 || in.charAt(0) != '=') { return in; } String original = in; // Replace all variables recursively int dollarPosition; int minPos = 0; while ((dollarPosition = in.indexOf('$', minPos)) != -1) { if (dollarPosition > 0 && in.charAt(dollarPosition - 1) == '\\') { // It was an escaped dollar break; } // Scan to next operator int endPosition = in.length(); boolean escaped = false; StringBuilder tokenBuilder = new StringBuilder(in.length()); tokenBuilder.append("$"); for (int i = dollarPosition + 1; i < in.length(); i ++) { char c = in.charAt(i); if (c == '\\') { if (escaped) { tokenBuilder.append(c); continue; } else { escaped = true; } } if (escaped) { continue; } else if (c == '+' || c == '-' || c == '/' || c == '*' || c == ',' || c == '$' || c == '(' || c == ')' || Character.isWhitespace(c)) { endPosition = i; break; } else { tokenBuilder.append(c); } } String token = tokenBuilder.toString();//in.substring(dollarPosition, endPosition); try { String decoded = decode(token); if (!decoded.equals(token)) { in = in.substring(0, dollarPosition) + decoded + in.substring(endPosition); } else { minPos = dollarPosition + 1; } } catch (Exception e) { System.err.println("Failed to parse "+original); throw e; } } StringBuilder ret = new StringBuilder(in.length() * 2); // First split the incoming string up into comma separated chunks, if any StringTokenizer st = new StringTokenizer(in.substring(1), ","); while (st.hasMoreTokens()) { String subElement = st.nextToken().trim(); if (subElement.charAt(0) == '=') { subElement = subElement.substring(1); } // Replace all escaped chars with themselves by simply removing the \ subElement = subElement.replace("\\", ""); try { // If it's a string, simply use verbatim if (Character.isJavaIdentifierStart(subElement.charAt(0))) { ret.append(subElement); } else { ret.append(String.valueOf(Expression.eval(subElement))); } } catch (Exception e) { // It wasn't an expression after all. Or maybe there was an error ret.append(subElement); System.err.println("Failed to parse "+original+" (sent "+subElement+" to parser)"); // throw e; } if (st.hasMoreTokens()) { ret.append(','); } } //System.out.println(in+" resolved to: "+ret.toString()); return ret.toString(); } /** * @return true if the specified attribute is present and not empty or null in the element */ public static boolean hasAttribute(Element element, String attribute) { String s = element.getAttribute(attribute); if (s == null || "".equals(s)) { return false; } else { return true; } } /** * Determine whether a single child is available of a particular type. * @return true if the specified child element is present * @throws Exception if the child is present multiple times. */ public static boolean hasChild(Element element, String child) throws Exception { NodeList nodes = element.getChildNodes(); Element ret = null; for (int i = 0; i < nodes.getLength(); i ++) { Node childNode = nodes.item(i); if (childNode.getNodeName().equals(child) && childNode.getNodeType() == Node.ELEMENT_NODE) { if (ret != null) { throw new Exception("Child element '"+child+"' present multiple times"); } else { ret = (Element) childNode; } } } return ret != null; } /** * @param child * @return the single child element or null * @throws Exception if the child is present multiple times */ public static Element getChild(Element element, String child) throws Exception { NodeList nodes = element.getChildNodes(); Element ret = null; for (int i = 0; i < nodes.getLength(); i ++) { Node childNode = nodes.item(i); if (childNode.getNodeName().equalsIgnoreCase(child) && childNode.getNodeType() == Node.ELEMENT_NODE) { if (ret != null) { throw new Exception("Child element '"+child+"' present multiple times"); } else { ret = (Element) childNode; } } } if (ret == null) { return null; } else { return ret; } } /** * @param child * @return the single child element or null * @throws Exception if the child is present multiple times */ public static Element getFirstChild(Element element, String child) throws Exception { NodeList nodes = element.getChildNodes(); Element ret = null; for (int i = 0; i < nodes.getLength(); i ++) { Node childNode = nodes.item(i); if (childNode.getNodeName().equalsIgnoreCase(child) && childNode.getNodeType() == Node.ELEMENT_NODE) { ret = (Element) childNode; return ret; } } return null; } /** * @param name The name of the child elements you want * @return a List of child Elements */ public static List<Element> getChildren(Element element, String name) throws Exception { NodeList nodes = element.getChildNodes(); ArrayList<Element> ret = new ArrayList<Element>(nodes.getLength()); for (int i = 0; i < nodes.getLength(); i ++) { Node childNode = nodes.item(i); if (childNode.getNodeName().equals(name) && childNode.getNodeType() == Node.ELEMENT_NODE) { ret.add((Element) childNode); } } return ret; } /** * @return a List of child Elements */ public static List<Element> getChildren(Element element) throws Exception { NodeList nodes = element.getChildNodes(); ArrayList<Element> ret = new ArrayList<Element>(nodes.getLength()); for (int i = 0; i < nodes.getLength(); i ++) { Node childNode = nodes.item(i); if (childNode.getNodeType() == Node.ELEMENT_NODE) { ret.add((Element) childNode); } } return ret; } /** * A convenience method for getting boolean values out of XML elements. * Booleans are true if the string value is "true", ignoring case. * @param attribute The name of the attribute * @throws NumberFormatException If the supplied attribute is not a number * @throws Exception if the value is missing * @return the parsed integer value */ public static boolean getBoolean(Element element, String attribute) throws Exception { String s = parse(element.getAttribute(attribute)); if (s == null || "".equals(s)) { throw new Exception("Attribute '"+attribute+"' has not been specified for "+element.getNodeName()); } else { return Boolean.valueOf(s).booleanValue(); } } public static boolean getOptionalBoolean(Element element, String attribute, boolean defaultValue) { String s = element.getAttribute(attribute); if (s == null || "".equals(s)) { return defaultValue; } else { return Boolean.valueOf(s).booleanValue(); } } /** * A convenience method for getting boolean values out of XML elements. * Booleans are true if the string value is "true", ignoring case. * @param attribute The name of the attribute * @param defaultValue The default value to return if no default is specified * @throws NumberFormatException If the supplied attribute is not a number * @return the parsed integer value */ public static boolean getBoolean(Element element, String attribute, boolean defaultValue) throws Exception { String s = parse(element.getAttribute(attribute)); if (s == null || "".equals(s)) { return defaultValue; } else { return Boolean.valueOf(s).booleanValue(); } } /** * A convenience method for getting float values out of XML elements * @param attribute The name of the attribute * @throws NumberFormatException If the supplied attribute is not a number * @throws Exception if the value is missing * @return the parsed float value */ public static float getFloat(Element element, String attribute) throws Exception { String s = parse(element.getAttribute(attribute)); if (s == null || "".equals(s)) { throw new Exception("Attribute '"+attribute+"' has not been specified for "+element.getNodeName()); } else { return Float.parseFloat(s); } } /** * A convenience method for getting float values out of XML elements * @param attribute The name of the attribute * @param defaultValue The default value to return if no default is specified * @throws NumberFormatException If the supplied attribute is not a number * @return the parsed float value */ public static float getFloat(Element element, String attribute, float defaultValue) throws Exception { String s = parse(element.getAttribute(attribute)); if (s == null || "".equals(s)) { return defaultValue; } else { return Float.parseFloat(s); } } /** * A convenience method for getting integer values out of XML elements * @param attribute The name of the attribute * @throws NumberFormatException If the supplied attribute is not a number * @throws Exception if the value is missing * @return the parsed integer value */ public static int getInt(Element element, String attribute) throws Exception { String s = parse(element.getAttribute(attribute)); if (s == null || "".equals(s)) { throw new Exception("Attribute '"+attribute+"' has not been specified for "+element.getNodeName()); } else { // Small hack to allow strings like "+1" to be recognised if (s.startsWith("+")) { s = s.substring(1); } return (int) (Float.parseFloat(s)); } } public static int getOptionalInt(Element element, String attribute, int defaultValue) { if (element.hasAttribute(attribute)) { String s = element.getAttribute(attribute); if (s.startsWith("+")) { s = s.substring(1); } return Integer.parseInt(s); } return defaultValue; } /** * A convenience method for getting integer values out of XML elements * @param attribute The name of the attribute * @param defaultValue The default value to return if no default is specified * @throws NumberFormatException If the supplied attribute is not a number * @return the parsed integer value */ public static int getInt(Element element, String attribute, int defaultValue) throws Exception { String s = parse(element.getAttribute(attribute)); if (s == null || "".equals(s)) { return defaultValue; } else { return (int) (Float.parseFloat(s)); } } /** * A convenience method for getting string values out of XML elements * @param attribute The name of the attribute * @return the string value, which will not be null * @throws Exception the value is not specified */ public static String getString(Element element, String attribute) throws Exception { String s; try { s = parse(element.getAttribute(attribute)); } catch (Exception e) { System.err.println("Failed to parse attribute '"+attribute+"' in element "+element.getNodeName()); throw e; } if (s == null || "".equals(s)) { throw new Exception("Attribute '"+attribute+"' has not been specified for "+element.getNodeName()); } else { return s; } } /** * A convenience method for getting string values out of XML elements * @param attribute The name of the attribute * @param defaultValue The default value to return if no default is specified * @return the string value, which will not be null */ public static String getString(Element element, String attribute, String defaultValue) throws Exception { String s = parse(element.getAttribute(attribute)); if (s == null || "".equals(s)) { return defaultValue; } else { return s; } } /** * Grab the text data inside a node. You can reference TextResources already loaded by using =<resource> * @param defaultText The default text to use if none is present * @return String */ public static String getText(Element element, String defaultText) throws Exception { NodeList children = element.getChildNodes(); for (int i = 0; i < children.getLength(); i ++) { Node child = children.item(i); if (child instanceof Text) { Text text = (Text) child; String ret = text.getData().trim(); if (ret.equals("")) { continue; } if (ret.startsWith("=")) { // It's a reference to a TextWrapper TextWrapper wrapper = (TextWrapper) Resources.get(ret.substring(1)); if (wrapper == null) { throw new NullPointerException("Failed to find "+ret.substring(1)+" text resource."); } ret = wrapper.getText(); } else { // Replace escape characters ret = ret.replace("\\n", "\n"); ret = ret.replace("\\t", "\t"); //// ret = ret.replaceAll("\\\\n", "\n"); //// ret = ret.replaceAll("\\\\t", "\t"); } return ret; } } return defaultText; } /** * Use reflection to read XML attributes into simple instance variables of the same name. * @param destination The object to read into * @param root The root class that we should use * @param element * @throws Exception */ @SuppressWarnings({"unchecked", "rawtypes"}) public static void grabXMLAttributes(Resource.Loader loader, Object destination, Class<?> root, Element element) throws Exception { Class<?> clazz = destination.getClass(); while (root.isAssignableFrom(clazz)) { Field[] fields = clazz.getDeclaredFields(); ArrayList<Field> sortedStringFields = new ArrayList<Field>(fields.length / 2 + 1); // Sort in descending order of field name length class LengthSorter implements Comparator<Field> { @Override public int compare(Field fa, Field fb) { int fal = fa.getName().length(); int fbl = fb.getName().length(); if (fal > fbl) { return -1; } else if (fal < fbl) { return 1; } else { return 0; } } } LengthSorter sorter = new LengthSorter(); for (int i = 0; i < fields.length; i ++) { Field f = fields[i]; // Set all fields accessible f.setAccessible(true); // Ignore a few well-known fields String fieldName = f.getName().toLowerCase(); if (fieldName.equals("name") || fieldName.equals("class") || fieldName.equals("autoCreated") || fieldName.equals("inherit")) { continue; } // ordinary string fields: // If the field is final or static or transient then ignore it int modifiers = f.getModifiers(); // Ignore final and static fields if (Modifier.isStatic(modifiers) || Modifier.isFinal(modifiers)) { continue; } if (!Modifier.isTransient(modifiers) && f.getType() == String.class) { sortedStringFields.add(f); } } Collections.sort(sortedStringFields, sorter); for (int i = 0; i < fields.length; i ++) { // If the field is final then ignore it Field currentField = fields[i]; if (Modifier.isFinal(currentField.getModifiers()) || Modifier.isStatic(currentField.getModifiers())) { continue; } if (Modifier.isTransient(currentField.getModifiers())) { // The special case of the transient field. There might be an embedded object in there, so instead of having to use a name // string and a named object elsewhere, we'll manufacture a name and do it all behind the scenes. // First: is it a resource? if (!IResource.class.isAssignableFrom(currentField.getType())) { continue; } // Second: is there a corresponding non-final non-transient non-static String field thats name is a prefix of this // field that's null? Field prefix = null; for (Iterator<Field> ssi = sortedStringFields.iterator(); ssi.hasNext(); ) { prefix = ssi.next(); if (currentField.getName().toLowerCase().startsWith(prefix.getName().toLowerCase()) && prefix.get(destination) == null) { // Yay! break; } else { prefix = null; } } if (prefix == null) { // No good, can't find one. continue; } // Third: find a child element with the same name as the field. If there's one and only one, and it contains a matching // Resource, we'll do our magic. Element child = XMLUtil.getChild(element, prefix.getName().toLowerCase()); if (child != null) { // Got a child. So now we'll load the child of the child List<Element> childrenOfChild = XMLUtil.getChildren(child); if (childrenOfChild.size() == 1) { // If there's an old resource, destroy it IResource oldResource = (IResource) currentField.get(destination); if (oldResource != null) { if (oldResource.isCreated()) { oldResource.destroy(); } currentField.set(destination, null); } IResource resource = loader.load(childrenOfChild.get(0)); if (currentField.getType().isAssignableFrom(resource.getClass())) { // OK. We've got a brand new Resource. Unfortunately it's transient so it won't be persisted here. What we'll do then is // stuff its manufactured name in the String field instead. prefix.set(destination, resource.getName()); continue; } else { throw new Exception("Child resource "+resource+" not compatible with field "+currentField.getName()+" of "+destination); } } } } String attribute = XMLUtil.getString(element, currentField.getName(), null); // If not found, try to find an all lower case match if (attribute == null) { attribute = XMLUtil.getString(element, currentField.getName().toLowerCase(), null); } if (attribute == null) { // Is it a Resource? if (!IResource.class.isAssignableFrom(currentField.getType())) { continue; } /* * It's not an attribute.. perhaps it's a child resource feature. These are * present in the following format: * <fieldname><childtag .... ></fieldname> */ Element child = XMLUtil.getChild(element, currentField.getName().toLowerCase()); if (child != null) { // Got a child. So now we'll load the child of the child List<Element> childrenOfChild = XMLUtil.getChildren(child); if (childrenOfChild.size() == 0) { // No child present, so assume null } else if (childrenOfChild.size() == 1) { // If there's an old resource, destroy it IResource oldResource = (IResource) currentField.get(destination); if (oldResource != null) { if (oldResource.isCreated()) { oldResource.destroy(); } currentField.set(destination, null); } IResource resource = loader.load(childrenOfChild.get(0)); if (currentField.getType().isAssignableFrom(resource.getClass())) { currentField.set(destination, resource); } else { throw new Exception("Child resource "+resource+" not compatible with field "+currentField.getName()+" of "+destination); } } else { // Multiple children present! Best to ignore it. continue; } continue; } continue; } if (currentField.getType().equals(int.class)) { if (attribute.endsWith("fp")) { currentField.setInt(destination, FPMath.parse(attribute)); } else { currentField.setInt(destination, (int) Float.parseFloat(attribute)); } } else if (currentField.getType().equals(int[].class)) { currentField.set(destination, ArrayParser.parseInts(attribute)); } else if (currentField.getType().equals(short.class)) { currentField.setShort(destination, (short) Float.parseFloat(attribute)); } else if (currentField.getType().equals(byte.class)) { currentField.setByte(destination, (byte) Float.parseFloat(attribute)); } else if (currentField.getType().equals(String.class)) { // Replace \n, \t etc if (attribute != null && attribute.indexOf("\\") != -1) { attribute = attribute.replace("\\n", "\n"); } currentField.set(destination, attribute); } else if (currentField.getType().equals(float.class)) { currentField.setFloat(destination, Float.parseFloat(attribute)); } else if (currentField.getType().equals(float[].class)) { currentField.set(destination, ArrayParser.parseFloats(attribute)); } else if (currentField.getType().equals(double.class)) { currentField.setDouble(destination, Double.parseDouble(attribute)); } else if (currentField.getType().equals(double[].class)) { currentField.set(destination, ArrayParser.parseDoubles(attribute)); } else if (currentField.getType().equals(char.class)) { currentField.setChar(destination, attribute.charAt(0)); } else if (currentField.getType().equals(boolean.class)) { currentField.setBoolean(destination, Boolean.valueOf(attribute).booleanValue()); } else if (currentField.getType() == Rectangle.class) { currentField.set(destination, RectangleParser.parse(attribute)); } else if (currentField.getType() == Point2f.class) { currentField.set(destination, Point2fParser.parse(attribute)); } else if (currentField.getType() == Point.class) { currentField.set(destination, PointParser.parse(attribute)); } else if (currentField.getType() == Vector3f.class) { currentField.set(destination, Vector3fParser.parse(attribute)); } else if (currentField.getType() == Dimension.class) { currentField.set(destination, DimensionParser.parse(attribute)); } else if (currentField.getType() == Color.class) { currentField.set(destination, ColorParser.parse(attribute)); } else if (Enum.class.isAssignableFrom(currentField.getType())) { currentField.set(destination, Enum.valueOf((Class) currentField.getType(), attribute)); } else if (Decodeable.class.isAssignableFrom(currentField.getType())) { Method decodeMethod = currentField.getType().getDeclaredMethod("decode", new Class[] {String.class}); currentField.set(destination, decodeMethod.invoke(currentField.getType(), new Object[] {attribute})); } else if (Parseable.class.isAssignableFrom(currentField.getType())) { // It's a parseable object, so create a new instance of it Parseable parseable = (Parseable) currentField.getType().newInstance(); // and decode it parseable.fromString(attribute); currentField.set(destination, parseable); } else { // Ignore this field - it's an Object type so we should handle it manually } } // Get next class up... clazz = clazz.getSuperclass(); } } /** * Scans through the list of child elements * @param resource * @param resourceElement * @throws Exception */ public static void loadChildResources(IResource resource, Element resourceElement, Resource.Loader loader) throws Exception { // Get a list of the non-transient non-final non-static null Resource fields in the incoming Resource // and load elements that correspond to them. Class<?> clazz = resource.getClass(); while (IResource.class.isAssignableFrom(clazz)) { Field[] fields = clazz.getDeclaredFields(); for (int i = 0; i < fields.length; i ++) { Field f = fields[i]; f.setAccessible(true); // If the field is final etc. then ignore it if (Modifier.isFinal(f.getModifiers()) || Modifier.isStatic(f.getModifiers()) || Modifier.isTransient(f.getModifiers())) { continue; } // If it's already set, ignore it Object value = f.get(resource); if (value != null) { continue; } // If it's not a Resource, ignore it if (!IResource.class.isAssignableFrom(f.getType())) { continue; } // Look for a child element with the same name Element childElement = getFirstChild(resourceElement, f.getName()); if (childElement != null) { Resource newResource = (Resource) f.getType().newInstance(); newResource.load(childElement, loader); f.set(resource, newResource); } } // Get next class up... clazz = clazz.getSuperclass(); } getChildren(resourceElement); } public static float getNormalisedFloat(Element element, String string, float defaultValue) throws Exception { assert (defaultValue >= 0f && defaultValue <= 1f); float val = getFloat(element, string, defaultValue); val = val < 0f ? 0f : val; val = val > 1f ? 1f : val; return val; } }