// ================================================================================================= // ADOBE SYSTEMS INCORPORATED // Copyright 2006 Adobe Systems Incorporated // All Rights Reserved // // NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms // of the Adobe license agreement accompanying it. // ================================================================================================= package com.adobe.xmp.impl; import java.util.Iterator; import com.adobe.xmp.XMPConst; import com.adobe.xmp.XMPError; import com.adobe.xmp.XMPException; import com.adobe.xmp.XMPMeta; import com.adobe.xmp.XMPMetaFactory; import com.adobe.xmp.XMPUtils; import com.adobe.xmp.impl.xpath.XMPPath; import com.adobe.xmp.impl.xpath.XMPPathParser; import com.adobe.xmp.options.PropertyOptions; import com.adobe.xmp.properties.XMPAliasInfo; /** * @since 11.08.2006 */ public class XMPUtilsImpl implements XMPConst { /** */ private static final int UCK_NORMAL = 0; /** */ private static final int UCK_SPACE = 1; /** */ private static final int UCK_COMMA = 2; /** */ private static final int UCK_SEMICOLON = 3; /** */ private static final int UCK_QUOTE = 4; /** */ private static final int UCK_CONTROL = 5; /** * Private constructor, as */ private XMPUtilsImpl() { // EMPTY } /** * @see XMPUtils#catenateArrayItems(XMPMeta, String, String, String, String, * boolean) * * @param xmp * The XMP object containing the array to be catenated. * @param schemaNS * The schema namespace URI for the array. Must not be null or * the empty string. * @param arrayName * The name of the array. May be a general path expression, must * not be null or the empty string. Each item in the array must * be a simple string value. * @param separator * The string to be used to separate the items in the catenated * string. Defaults to "; ", ASCII semicolon and space * (U+003B, U+0020). * @param quotes * The characters to be used as quotes around array items that * contain a separator. Defaults to '"' * @param allowCommas * Option flag to control the catenation. * @return Returns the string containing the catenated array items. * @throws XMPException * Forwards the Exceptions from the metadata processing */ public static String catenateArrayItems(XMPMeta xmp, String schemaNS, String arrayName, String separator, String quotes, boolean allowCommas) throws XMPException { ParameterAsserts.assertSchemaNS(schemaNS); ParameterAsserts.assertArrayName(arrayName); ParameterAsserts.assertImplementation(xmp); if (separator == null || separator.length() == 0) { separator = "; "; } if (quotes == null || quotes.length() == 0) { quotes = "\""; } XMPMetaImpl xmpImpl = (XMPMetaImpl) xmp; XMPNode arrayNode = null; XMPNode currItem = null; // Return an empty result if the array does not exist, // hurl if it isn't the right form. XMPPath arrayPath = XMPPathParser.expandXPath(schemaNS, arrayName); arrayNode = XMPNodeUtils.findNode(xmpImpl.getRoot(), arrayPath, false, null); if (arrayNode == null) { return ""; } else if (!arrayNode.getOptions().isArray() || arrayNode.getOptions().isArrayAlternate()) { throw new XMPException("Named property must be non-alternate array", XMPError.BADPARAM); } // Make sure the separator is OK. checkSeparator(separator); // Make sure the open and close quotes are a legitimate pair. char openQuote = quotes.charAt(0); char closeQuote = checkQuotes(quotes, openQuote); // Build the result, quoting the array items, adding separators. // Hurl if any item isn't simple. StringBuffer catinatedString = new StringBuffer(); for (Iterator it = arrayNode.iterateChildren(); it.hasNext();) { currItem = (XMPNode) it.next(); if (currItem.getOptions().isCompositeProperty()) { throw new XMPException("Array items must be simple", XMPError.BADPARAM); } String str = applyQuotes(currItem.getValue(), openQuote, closeQuote, allowCommas); catinatedString.append(str); if (it.hasNext()) { catinatedString.append(separator); } } return catinatedString.toString(); } /** * see {@link XMPUtils#separateArrayItems(XMPMeta, String, String, String, * PropertyOptions, boolean)} * * @param xmp * The XMP object containing the array to be updated. * @param schemaNS * The schema namespace URI for the array. Must not be null or * the empty string. * @param arrayName * The name of the array. May be a general path expression, must * not be null or the empty string. Each item in the array must * be a simple string value. * @param catedStr * The string to be separated into the array items. * @param arrayOptions * Option flags to control the separation. * @param preserveCommas * Flag if commas shall be preserved * * @throws XMPException * Forwards the Exceptions from the metadata processing */ public static void separateArrayItems(XMPMeta xmp, String schemaNS, String arrayName, String catedStr, PropertyOptions arrayOptions, boolean preserveCommas) throws XMPException { ParameterAsserts.assertSchemaNS(schemaNS); ParameterAsserts.assertArrayName(arrayName); if (catedStr == null) { throw new XMPException("Parameter must not be null", XMPError.BADPARAM); } ParameterAsserts.assertImplementation(xmp); XMPMetaImpl xmpImpl = (XMPMetaImpl) xmp; // Keep a zero value, has special meaning below. XMPNode arrayNode = separateFindCreateArray(schemaNS, arrayName, arrayOptions, xmpImpl); // Extract the item values one at a time, until the whole input string is done. String itemValue; int itemStart, itemEnd; int nextKind = UCK_NORMAL, charKind = UCK_NORMAL; char ch = 0, nextChar = 0; itemEnd = 0; int endPos = catedStr.length(); while (itemEnd < endPos) { // Skip any leading spaces and separation characters. Always skip commas here. // They can be kept when within a value, but not when alone between values. for (itemStart = itemEnd; itemStart < endPos; itemStart++) { ch = catedStr.charAt(itemStart); charKind = classifyCharacter(ch); if (charKind == UCK_NORMAL || charKind == UCK_QUOTE) { break; } } if (itemStart >= endPos) { break; } if (charKind != UCK_QUOTE) { // This is not a quoted value. Scan for the end, create an array // item from the substring. for (itemEnd = itemStart; itemEnd < endPos; itemEnd++) { ch = catedStr.charAt(itemEnd); charKind = classifyCharacter(ch); if (charKind == UCK_NORMAL || charKind == UCK_QUOTE || (charKind == UCK_COMMA && preserveCommas)) { continue; } else if (charKind != UCK_SPACE) { break; } else if ((itemEnd + 1) < endPos) { ch = catedStr.charAt(itemEnd + 1); nextKind = classifyCharacter(ch); if (nextKind == UCK_NORMAL || nextKind == UCK_QUOTE || (nextKind == UCK_COMMA && preserveCommas)) { continue; } } // Anything left? break; // Have multiple spaces, or a space followed by a // separator. } itemValue = catedStr.substring(itemStart, itemEnd); } else { // Accumulate quoted values into a local string, undoubling // internal quotes that // match the surrounding quotes. Do not undouble "unmatching" // quotes. char openQuote = ch; char closeQuote = getClosingQuote(openQuote); itemStart++; // Skip the opening quote; itemValue = ""; for (itemEnd = itemStart; itemEnd < endPos; itemEnd++) { ch = catedStr.charAt(itemEnd); charKind = classifyCharacter(ch); if (charKind != UCK_QUOTE || !isSurroundingQuote(ch, openQuote, closeQuote)) { // This is not a matching quote, just append it to the // item value. itemValue += ch; } else { // This is a "matching" quote. Is it doubled, or the // final closing quote? // Tolerate various edge cases like undoubled opening // (non-closing) quotes, // or end of input. if ((itemEnd + 1) < endPos) { nextChar = catedStr.charAt(itemEnd + 1); nextKind = classifyCharacter(nextChar); } else { nextKind = UCK_SEMICOLON; nextChar = 0x3B; } if (ch == nextChar) { // This is doubled, copy it and skip the double. itemValue += ch; // Loop will add in charSize. itemEnd++; } else if (!isClosingingQuote(ch, openQuote, closeQuote)) { // This is an undoubled, non-closing quote, copy it. itemValue += ch; } else { // This is an undoubled closing quote, skip it and // exit the loop. itemEnd++; break; } } } } // Add the separated item to the array. // Keep a matching old value in case it had separators. int foundIndex = -1; for (int oldChild = 1; oldChild <= arrayNode.getChildrenLength(); oldChild++) { if (itemValue.equals(arrayNode.getChild(oldChild).getValue())) { foundIndex = oldChild; break; } } XMPNode newItem = null; if (foundIndex < 0) { newItem = new XMPNode(ARRAY_ITEM_NAME, itemValue, null); arrayNode.addChild(newItem); } } } /** * Utility to find or create the array used by separateArrayItems(). * @param schemaNS a the namespace fo the array * @param arrayName the name of the array * @param arrayOptions the options for the array if newly created * @param xmp the xmp object * @return Returns the array node. * @throws XMPException Forwards exceptions */ private static XMPNode separateFindCreateArray(String schemaNS, String arrayName, PropertyOptions arrayOptions, XMPMetaImpl xmp) throws XMPException { arrayOptions = XMPNodeUtils.verifySetOptions(arrayOptions, null); if (!arrayOptions.isOnlyArrayOptions()) { throw new XMPException("Options can only provide array form", XMPError.BADOPTIONS); } // Find the array node, make sure it is OK. Move the current children // aside, to be readded later if kept. XMPPath arrayPath = XMPPathParser.expandXPath(schemaNS, arrayName); XMPNode arrayNode = XMPNodeUtils.findNode(xmp.getRoot(), arrayPath, false, null); if (arrayNode != null) { // The array exists, make sure the form is compatible. Zero // arrayForm means take what exists. PropertyOptions arrayForm = arrayNode.getOptions(); if (!arrayForm.isArray() || arrayForm.isArrayAlternate()) { throw new XMPException("Named property must be non-alternate array", XMPError.BADXPATH); } if (arrayOptions.equalArrayTypes(arrayForm)) { throw new XMPException("Mismatch of specified and existing array form", XMPError.BADXPATH); // *** Right error? } } else { // The array does not exist, try to create it. // don't modify the options handed into the method arrayNode = XMPNodeUtils.findNode(xmp.getRoot(), arrayPath, true, arrayOptions .setArray(true)); if (arrayNode == null) { throw new XMPException("Failed to create named array", XMPError.BADXPATH); } } return arrayNode; } /** * @see XMPUtils#removeProperties(XMPMeta, String, String, boolean, boolean) * * @param xmp * The XMP object containing the properties to be removed. * * @param schemaNS * Optional schema namespace URI for the properties to be * removed. * * @param propName * Optional path expression for the property to be removed. * * @param doAllProperties * Option flag to control the deletion: do internal properties in * addition to external properties. * @param includeAliases * Option flag to control the deletion: Include aliases in the * "named schema" case above. * @throws XMPException If metadata processing fails */ public static void removeProperties(XMPMeta xmp, String schemaNS, String propName, boolean doAllProperties, boolean includeAliases) throws XMPException { ParameterAsserts.assertImplementation(xmp); XMPMetaImpl xmpImpl = (XMPMetaImpl) xmp; if (propName != null && propName.length() > 0) { // Remove just the one indicated property. This might be an alias, // the named schema might not actually exist. So don't lookup the // schema node. if (schemaNS == null || schemaNS.length() == 0) { throw new XMPException("Property name requires schema namespace", XMPError.BADPARAM); } XMPPath expPath = XMPPathParser.expandXPath(schemaNS, propName); XMPNode propNode = XMPNodeUtils.findNode(xmpImpl.getRoot(), expPath, false, null); if (propNode != null) { if (doAllProperties || !Utils.isInternalProperty(expPath.getSegment(XMPPath.STEP_SCHEMA) .getName(), expPath.getSegment(XMPPath.STEP_ROOT_PROP).getName())) { XMPNode parent = propNode.getParent(); parent.removeChild(propNode); if (parent.getOptions().isSchemaNode() && !parent.hasChildren()) { // remove empty schema node parent.getParent().removeChild(parent); } } } } else if (schemaNS != null && schemaNS.length() > 0) { // Remove all properties from the named schema. Optionally include // aliases, in which case // there might not be an actual schema node. // XMP_NodePtrPos schemaPos; XMPNode schemaNode = XMPNodeUtils.findSchemaNode(xmpImpl.getRoot(), schemaNS, false); if (schemaNode != null) { if (removeSchemaChildren(schemaNode, doAllProperties)) { xmpImpl.getRoot().removeChild(schemaNode); } } if (includeAliases) { // We're removing the aliases also. Look them up by their // namespace prefix. // But that takes more code and the extra speed isn't worth it. // Lookup the XMP node // from the alias, to make sure the actual exists. XMPAliasInfo[] aliases = XMPMetaFactory.getSchemaRegistry().findAliases(schemaNS); for (int i = 0; i < aliases.length; i++) { XMPAliasInfo info = aliases[i]; XMPPath path = XMPPathParser.expandXPath(info.getNamespace(), info .getPropName()); XMPNode actualProp = XMPNodeUtils .findNode(xmpImpl.getRoot(), path, false, null); if (actualProp != null) { XMPNode parent = actualProp.getParent(); parent.removeChild(actualProp); } } } } else { // Remove all appropriate properties from all schema. In this case // we don't have to be // concerned with aliases, they are handled implicitly from the // actual properties. for (Iterator it = xmpImpl.getRoot().iterateChildren(); it.hasNext();) { XMPNode schema = (XMPNode) it.next(); if (removeSchemaChildren(schema, doAllProperties)) { it.remove(); } } } } /** * @see XMPUtils#appendProperties(XMPMeta, XMPMeta, boolean, boolean) * @param source The source XMP object. * @param destination The destination XMP object. * @param doAllProperties Do internal properties in addition to external properties. * @param replaceOldValues Replace the values of existing properties. * @param deleteEmptyValues Delete destination values if source property is empty. * @throws XMPException Forwards the Exceptions from the metadata processing */ public static void appendProperties(XMPMeta source, XMPMeta destination, boolean doAllProperties, boolean replaceOldValues, boolean deleteEmptyValues) throws XMPException { ParameterAsserts.assertImplementation(source); ParameterAsserts.assertImplementation(destination); XMPMetaImpl src = (XMPMetaImpl) source; XMPMetaImpl dest = (XMPMetaImpl) destination; for (Iterator it = src.getRoot().iterateChildren(); it.hasNext();) { XMPNode sourceSchema = (XMPNode) it.next(); // Make sure we have a destination schema node XMPNode destSchema = XMPNodeUtils.findSchemaNode(dest.getRoot(), sourceSchema.getName(), false); boolean createdSchema = false; if (destSchema == null) { destSchema = new XMPNode(sourceSchema.getName(), sourceSchema.getValue(), new PropertyOptions().setSchemaNode(true)); dest.getRoot().addChild(destSchema); createdSchema = true; } // Process the source schema's children. for (Iterator ic = sourceSchema.iterateChildren(); ic.hasNext();) { XMPNode sourceProp = (XMPNode) ic.next(); if (doAllProperties || !Utils.isInternalProperty(sourceSchema.getName(), sourceProp.getName())) { appendSubtree( dest, sourceProp, destSchema, replaceOldValues, deleteEmptyValues); } } if (!destSchema.hasChildren() && (createdSchema || deleteEmptyValues)) { // Don't create an empty schema / remove empty schema. dest.getRoot().removeChild(destSchema); } } } /** * Remove all schema children according to the flag * doAllProperties. Empty schemas are automatically remove * by XMPNode * * @param schemaNode * a schema node * @param doAllProperties * flag if all properties or only externals shall be removed. * @return Returns true if the schema is empty after the operation. */ private static boolean removeSchemaChildren(XMPNode schemaNode, boolean doAllProperties) { for (Iterator it = schemaNode.iterateChildren(); it.hasNext();) { XMPNode currProp = (XMPNode) it.next(); if (doAllProperties || !Utils.isInternalProperty(schemaNode.getName(), currProp.getName())) { it.remove(); } } return !schemaNode.hasChildren(); } /** * @see XMPUtilsImpl#appendProperties(XMPMeta, XMPMeta, boolean, boolean, boolean) * @param destXMP The destination XMP object. * @param sourceNode the source node * @param destParent the parent of the destination node * @param replaceOldValues Replace the values of existing properties. * @param deleteEmptyValues flag if properties with empty values should be deleted * in the destination object. * @throws XMPException */ private static void appendSubtree(XMPMetaImpl destXMP, XMPNode sourceNode, XMPNode destParent, boolean replaceOldValues, boolean deleteEmptyValues) throws XMPException { XMPNode destNode = XMPNodeUtils.findChildNode(destParent, sourceNode.getName(), false); boolean valueIsEmpty = false; if (deleteEmptyValues) { valueIsEmpty = sourceNode.getOptions().isSimple() ? sourceNode.getValue() == null || sourceNode.getValue().length() == 0 : !sourceNode.hasChildren(); } if (deleteEmptyValues && valueIsEmpty) { if (destNode != null) { destParent.removeChild(destNode); } } else if (destNode == null) { // The one easy case, the destination does not exist. destParent.addChild((XMPNode) sourceNode.clone()); } else if (replaceOldValues) { // The destination exists and should be replaced. destXMP.setNode(destNode, sourceNode.getValue(), sourceNode.getOptions(), true); destParent.removeChild(destNode); destNode = (XMPNode) sourceNode.clone(); destParent.addChild(destNode); } else { // The destination exists and is not totally replaced. Structs and // arrays are merged. PropertyOptions sourceForm = sourceNode.getOptions(); PropertyOptions destForm = destNode.getOptions(); if (sourceForm != destForm) { return; } if (sourceForm.isStruct()) { // To merge a struct process the fields recursively. E.g. add simple missing fields. // The recursive call to AppendSubtree will handle deletion for fields with empty // values. for (Iterator it = sourceNode.iterateChildren(); it.hasNext();) { XMPNode sourceField = (XMPNode) it.next(); appendSubtree(destXMP, sourceField, destNode, replaceOldValues, deleteEmptyValues); if (deleteEmptyValues && !destNode.hasChildren()) { destParent.removeChild(destNode); } } } else if (sourceForm.isArrayAltText()) { // Merge AltText arrays by the "xml:lang" qualifiers. Make sure x-default is first. // Make a special check for deletion of empty values. Meaningful in AltText arrays // because the "xml:lang" qualifier provides unambiguous source/dest correspondence. for (Iterator it = sourceNode.iterateChildren(); it.hasNext();) { XMPNode sourceItem = (XMPNode) it.next(); if (!sourceItem.hasQualifier() || !XMPConst.XML_LANG.equals(sourceItem.getQualifier(1).getName())) { continue; } int destIndex = XMPNodeUtils.lookupLanguageItem(destNode, sourceItem.getQualifier(1).getValue()); if (deleteEmptyValues && (sourceItem.getValue() == null || sourceItem.getValue().length() == 0)) { if (destIndex != -1) { destNode.removeChild(destIndex); if (!destNode.hasChildren()) { destParent.removeChild(destNode); } } } else if (destIndex == -1) { // Not replacing, keep the existing item. if (!XMPConst.X_DEFAULT.equals(sourceItem.getQualifier(1).getValue()) || !destNode.hasChildren()) { sourceItem.cloneSubtree(destNode); } else { XMPNode destItem = new XMPNode( sourceItem.getName(), sourceItem.getValue(), sourceItem.getOptions()); sourceItem.cloneSubtree(destItem); destNode.addChild(1, destItem); } } } } else if (sourceForm.isArray()) { // Merge other arrays by item values. Don't worry about order or duplicates. Source // items with empty values do not cause deletion, that conflicts horribly with // merging. for (Iterator is = sourceNode.iterateChildren(); is.hasNext();) { XMPNode sourceItem = (XMPNode) is.next(); boolean match = false; for (Iterator id = destNode.iterateChildren(); id.hasNext();) { XMPNode destItem = (XMPNode) id.next(); if (itemValuesMatch(sourceItem, destItem)) { match = true; } } if (!match) { destNode = (XMPNode) sourceItem.clone(); destParent.addChild(destNode); } } } } } /** * Compares two nodes including its children and qualifier. * @param leftNode an XMPNode * @param rightNode an XMPNode * @return Returns true if the nodes are equal, false otherwise. * @throws XMPException Forwards exceptions to the calling method. */ private static boolean itemValuesMatch(XMPNode leftNode, XMPNode rightNode) throws XMPException { PropertyOptions leftForm = leftNode.getOptions(); PropertyOptions rightForm = rightNode.getOptions(); if (leftForm.equals(rightForm)) { return false; } if (leftForm.getOptions() == 0) { // Simple nodes, check the values and xml:lang qualifiers. if (!leftNode.getValue().equals(rightNode.getValue())) { return false; } if (leftNode.getOptions().getHasLanguage() != rightNode.getOptions().getHasLanguage()) { return false; } if (leftNode.getOptions().getHasLanguage() && !leftNode.getQualifier(1).getValue().equals( rightNode.getQualifier(1).getValue())) { return false; } } else if (leftForm.isStruct()) { // Struct nodes, see if all fields match, ignoring order. if (leftNode.getChildrenLength() != rightNode.getChildrenLength()) { return false; } for (Iterator it = leftNode.iterateChildren(); it.hasNext();) { XMPNode leftField = (XMPNode) it.next(); XMPNode rightField = XMPNodeUtils.findChildNode(rightNode, leftField.getName(), false); if (rightField == null || !itemValuesMatch(leftField, rightField)) { return false; } } } else { // Array nodes, see if the "leftNode" values are present in the // "rightNode", ignoring order, duplicates, // and extra values in the rightNode-> The rightNode is the // destination for AppendProperties. assert leftForm.isArray(); for (Iterator il = leftNode.iterateChildren(); il.hasNext();) { XMPNode leftItem = (XMPNode) il.next(); boolean match = false; for (Iterator ir = rightNode.iterateChildren(); ir.hasNext();) { XMPNode rightItem = (XMPNode) ir.next(); if (itemValuesMatch(leftItem, rightItem)) { match = true; break; } } if (!match) { return false; } } } return true; // All of the checks passed. } /** * Make sure the separator is OK. It must be one semicolon surrounded by * zero or more spaces. Any of the recognized semicolons or spaces are * allowed. * * @param separator * @throws XMPException */ private static void checkSeparator(String separator) throws XMPException { boolean haveSemicolon = false; for (int i = 0; i < separator.length(); i++) { int charKind = classifyCharacter(separator.charAt(i)); if (charKind == UCK_SEMICOLON) { if (haveSemicolon) { throw new XMPException("Separator can have only one semicolon", XMPError.BADPARAM); } haveSemicolon = true; } else if (charKind != UCK_SPACE) { throw new XMPException("Separator can have only spaces and one semicolon", XMPError.BADPARAM); } } if (!haveSemicolon) { throw new XMPException("Separator must have one semicolon", XMPError.BADPARAM); } } /** * Make sure the open and close quotes are a legitimate pair and return the * correct closing quote or an exception. * * @param quotes * opened and closing quote in a string * @param openQuote * the open quote * @return Returns a corresponding closing quote. * @throws XMPException */ private static char checkQuotes(String quotes, char openQuote) throws XMPException { char closeQuote; int charKind = classifyCharacter(openQuote); if (charKind != UCK_QUOTE) { throw new XMPException("Invalid quoting character", XMPError.BADPARAM); } if (quotes.length() == 1) { closeQuote = openQuote; } else { closeQuote = quotes.charAt(1); charKind = classifyCharacter(closeQuote); if (charKind != UCK_QUOTE) { throw new XMPException("Invalid quoting character", XMPError.BADPARAM); } } if (closeQuote != getClosingQuote(openQuote)) { throw new XMPException("Mismatched quote pair", XMPError.BADPARAM); } return closeQuote; } /** * Classifies the character into normal chars, spaces, semicola, quotes, * control chars. * * @param ch * a char * @return Return the character kind. */ private static int classifyCharacter(char ch) { if (SPACES.indexOf(ch) >= 0 || (0x2000 <= ch && ch <= 0x200B)) { return UCK_SPACE; } else if (COMMAS.indexOf(ch) >= 0) { return UCK_COMMA; } else if (SEMICOLA.indexOf(ch) >= 0) { return UCK_SEMICOLON; } else if (QUOTES.indexOf(ch) >= 0 || (0x3008 <= ch && ch <= 0x300F) || (0x2018 <= ch && ch <= 0x201F)) { return UCK_QUOTE; } else if (ch < 0x0020 || CONTROLS.indexOf(ch) >= 0) { return UCK_CONTROL; } else { // Assume typical case. return UCK_NORMAL; } } /** * @param openQuote * the open quote char * @return Returns the matching closing quote for an open quote. */ private static char getClosingQuote(char openQuote) { switch (openQuote) { case 0x0022: return 0x0022; // ! U+0022 is both opening and closing. case 0x005B: return 0x005D; case 0x00AB: return 0x00BB; // ! U+00AB and U+00BB are reversible. case 0x00BB: return 0x00AB; case 0x2015: return 0x2015; // ! U+2015 is both opening and closing. case 0x2018: return 0x2019; case 0x201A: return 0x201B; case 0x201C: return 0x201D; case 0x201E: return 0x201F; case 0x2039: return 0x203A; // ! U+2039 and U+203A are reversible. case 0x203A: return 0x2039; case 0x3008: return 0x3009; case 0x300A: return 0x300B; case 0x300C: return 0x300D; case 0x300E: return 0x300F; case 0x301D: return 0x301F; // ! U+301E also closes U+301D. default: return 0; } } /** * Add quotes to the item. * * @param item * the array item * @param openQuote * the open quote character * @param closeQuote * the closing quote character * @param allowCommas * flag if commas are allowed * @return Returns the value in quotes. */ private static String applyQuotes(String item, char openQuote, char closeQuote, boolean allowCommas) { if (item == null) { item = ""; } boolean prevSpace = false; int charOffset; int charKind; // See if there are any separators in the value. Stop at the first // occurrance. This is a bit // tricky in order to make typical typing work conveniently. The purpose // of applying quotes // is to preserve the values when splitting them back apart. That is // CatenateContainerItems // and SeparateContainerItems must round trip properly. For the most // part we only look for // separators here. Internal quotes, as in -- Irving "Bud" Jones -- // won't cause problems in // the separation. An initial quote will though, it will make the value // look quoted. int i; for (i = 0; i < item.length(); i++) { char ch = item.charAt(i); charKind = classifyCharacter(ch); if (i == 0 && charKind == UCK_QUOTE) { break; } if (charKind == UCK_SPACE) { // Multiple spaces are a separator. if (prevSpace) { break; } prevSpace = true; } else { prevSpace = false; if ((charKind == UCK_SEMICOLON || charKind == UCK_CONTROL) || (charKind == UCK_COMMA && !allowCommas)) { break; } } } if (i < item.length()) { // Create a quoted copy, doubling any internal quotes that match the // outer ones. Internal quotes did not stop the "needs quoting" // search, but they do need // doubling. So we have to rescan the front of the string for // quotes. Handle the special // case of U+301D being closed by either U+301E or U+301F. StringBuffer newItem = new StringBuffer(item.length() + 2); int splitPoint; for (splitPoint = 0; splitPoint <= i; splitPoint++) { if (classifyCharacter(item.charAt(i)) == UCK_QUOTE) { break; } } // Copy the leading "normal" portion. newItem.append(openQuote).append(item.substring(0, splitPoint)); for (charOffset = splitPoint; charOffset < item.length(); charOffset++) { newItem.append(item.charAt(charOffset)); if (classifyCharacter(item.charAt(charOffset)) == UCK_QUOTE && isSurroundingQuote(item.charAt(charOffset), openQuote, closeQuote)) { newItem.append(item.charAt(charOffset)); } } newItem.append(closeQuote); item = newItem.toString(); } return item; } /** * @param ch a character * @param openQuote the opening quote char * @param closeQuote the closing quote char * @return Return it the character is a surrounding quote. */ private static boolean isSurroundingQuote(char ch, char openQuote, char closeQuote) { return ch == openQuote || isClosingingQuote(ch, openQuote, closeQuote); } /** * @param ch a character * @param openQuote the opening quote char * @param closeQuote the closing quote char * @return Returns true if the character is a closing quote. */ private static boolean isClosingingQuote(char ch, char openQuote, char closeQuote) { return ch == closeQuote || (openQuote == 0x301D && ch == 0x301E || ch == 0x301F); } /** * U+0022 ASCII space
* U+3000, ideographic space
* U+303F, ideographic half fill space
* U+2000..U+200B, en quad through zero width space */ private static final String SPACES = "\u0020\u3000\u303F"; /** * U+002C, ASCII comma
* U+FF0C, full width comma
* U+FF64, half width ideographic comma
* U+FE50, small comma
* U+FE51, small ideographic comma
* U+3001, ideographic comma
* U+060C, Arabic comma
* U+055D, Armenian comma */ private static final String COMMAS = "\u002C\uFF0C\uFF64\uFE50\uFE51\u3001\u060C\u055D"; /** * U+003B, ASCII semicolon
* U+FF1B, full width semicolon
* U+FE54, small semicolon
* U+061B, Arabic semicolon
* U+037E, Greek "semicolon" (really a question mark) */ private static final String SEMICOLA = "\u003B\uFF1B\uFE54\u061B\u037E"; /** * U+0022 ASCII quote
* ASCII '[' (0x5B) and ']' (0x5D) are used as quotes in Chinese and * Korean.
* U+00AB and U+00BB, guillemet quotes
* U+3008..U+300F, various quotes.
* U+301D..U+301F, double prime quotes.
* U+2015, dash quote.
* U+2018..U+201F, various quotes.
* U+2039 and U+203A, guillemet quotes. */ private static final String QUOTES = "\"\u005B\u005D\u00AB\u00BB\u301D\u301E\u301F\u2015\u2039\u203A"; /** * U+0000..U+001F ASCII controls
* U+2028, line separator.
* U+2029, paragraph separator. */ private static final String CONTROLS = "\u2028\u2029"; }