blob: d51d7de756e7dc27b04ba62b5c81c57f4ba11fdb [file] [log] [blame]
// =================================================================================================
// 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.Calendar;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import com.adobe.xmp.XMPConst;
import com.adobe.xmp.XMPDateTime;
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.ParseOptions;
import com.adobe.xmp.options.PropertyOptions;
import com.adobe.xmp.properties.XMPAliasInfo;
/**
* @since Aug 18, 2006
*/
public class XMPNormalizer
{
/** caches the correct dc-property array forms */
private static Map dcArrayForms;
/** init char tables */
static
{
initDCArrays();
}
/**
* Hidden constructor
*/
private XMPNormalizer()
{
// EMPTY
}
/**
* Normalizes a raw parsed XMPMeta-Object
* @param xmp the raw metadata object
* @param options the parsing options
* @return Returns the normalized metadata object
* @throws XMPException Collects all severe processing errors.
*/
static XMPMeta process(XMPMetaImpl xmp, ParseOptions options) throws XMPException
{
XMPNode tree = xmp.getRoot();
touchUpDataModel(xmp);
moveExplicitAliases(tree, options);
tweakOldXMP(tree);
deleteEmptySchemas(tree);
return xmp;
}
/**
* Tweak old XMP: Move an instance ID from rdf:about to the
* <em>xmpMM:InstanceID</em> property. An old instance ID usually looks
* like &quot;uuid:bac965c4-9d87-11d9-9a30-000d936b79c4&quot;, plus InDesign
* 3.0 wrote them like &quot;bac965c4-9d87-11d9-9a30-000d936b79c4&quot;. If
* the name looks like a UUID simply move it to <em>xmpMM:InstanceID</em>,
* don't worry about any existing <em>xmpMM:InstanceID</em>. Both will
* only be present when a newer file with the <em>xmpMM:InstanceID</em>
* property is updated by an old app that uses <em>rdf:about</em>.
*
* @param tree the root of the metadata tree
* @throws XMPException Thrown if tweaking fails.
*/
private static void tweakOldXMP(XMPNode tree) throws XMPException
{
if (tree.getName() != null && tree.getName().length() >= Utils.UUID_LENGTH)
{
String nameStr = tree.getName().toLowerCase();
if (nameStr.startsWith("uuid:"))
{
nameStr = nameStr.substring(5);
}
if (Utils.checkUUIDFormat(nameStr))
{
// move UUID to xmpMM:InstanceID and remove it from the root node
XMPPath path = XMPPathParser.expandXPath(XMPConst.NS_XMP_MM, "InstanceID");
XMPNode idNode = XMPNodeUtils.findNode (tree, path, true, null);
if (idNode != null)
{
idNode.setOptions(null); // Clobber any existing xmpMM:InstanceID.
idNode.setValue("uuid:" + nameStr);
idNode.removeChildren();
idNode.removeQualifiers();
tree.setName(null);
}
else
{
throw new XMPException("Failure creating xmpMM:InstanceID",
XMPError.INTERNALFAILURE);
}
}
}
}
/**
* Visit all schemas to do general fixes and handle special cases.
*
* @param xmp the metadata object implementation
* @throws XMPException Thrown if the normalisation fails.
*/
private static void touchUpDataModel(XMPMetaImpl xmp) throws XMPException
{
// make sure the DC schema is existing, because it might be needed within the normalization
// if not touched it will be removed by removeEmptySchemas
XMPNodeUtils.findSchemaNode(xmp.getRoot(), XMPConst.NS_DC, true);
// Do the special case fixes within each schema.
for (Iterator it = xmp.getRoot().iterateChildren(); it.hasNext();)
{
XMPNode currSchema = (XMPNode) it.next();
if (XMPConst.NS_DC.equals(currSchema.getName()))
{
normalizeDCArrays(currSchema);
}
else if (XMPConst.NS_EXIF.equals(currSchema.getName()))
{
// Do a special case fix for exif:GPSTimeStamp.
fixGPSTimeStamp(currSchema);
XMPNode arrayNode = XMPNodeUtils.findChildNode(currSchema, "exif:UserComment",
false);
if (arrayNode != null)
{
repairAltText(arrayNode);
}
}
else if (XMPConst.NS_DM.equals(currSchema.getName()))
{
// Do a special case migration of xmpDM:copyright to
// dc:rights['x-default'].
XMPNode dmCopyright = XMPNodeUtils.findChildNode(currSchema, "xmpDM:copyright",
false);
if (dmCopyright != null)
{
migrateAudioCopyright(xmp, dmCopyright);
}
}
else if (XMPConst.NS_XMP_RIGHTS.equals(currSchema.getName()))
{
XMPNode arrayNode = XMPNodeUtils.findChildNode(currSchema, "xmpRights:UsageTerms",
false);
if (arrayNode != null)
{
repairAltText(arrayNode);
}
}
}
}
/**
* Undo the denormalization performed by the XMP used in Acrobat 5.<br>
* If a Dublin Core array had only one item, it was serialized as a simple
* property. <br>
* The <code>xml:lang</code> attribute was dropped from an
* <code>alt-text</code> item if the language was <code>x-default</code>.
*
* @param dcSchema the DC schema node
* @throws XMPException Thrown if normalization fails
*/
private static void normalizeDCArrays(XMPNode dcSchema) throws XMPException
{
for (int i = 1; i <= dcSchema.getChildrenLength(); i++)
{
XMPNode currProp = dcSchema.getChild(i);
PropertyOptions arrayForm = (PropertyOptions) dcArrayForms.get(currProp.getName());
if (arrayForm == null)
{
continue;
}
else if (currProp.getOptions().isSimple())
{
// create a new array and add the current property as child,
// if it was formerly simple
XMPNode newArray = new XMPNode(currProp.getName(), arrayForm);
currProp.setName(XMPConst.ARRAY_ITEM_NAME);
newArray.addChild(currProp);
dcSchema.replaceChild(i, newArray);
// fix language alternatives
if (arrayForm.isArrayAltText() && !currProp.getOptions().getHasLanguage())
{
XMPNode newLang = new XMPNode(XMPConst.XML_LANG, XMPConst.X_DEFAULT, null);
currProp.addQualifier(newLang);
}
}
else
{
// clear array options and add corrected array form if it has been an array before
currProp.getOptions().setOption(
PropertyOptions.ARRAY |
PropertyOptions.ARRAY_ORDERED |
PropertyOptions.ARRAY_ALTERNATE |
PropertyOptions.ARRAY_ALT_TEXT,
false);
currProp.getOptions().mergeWith(arrayForm);
if (arrayForm.isArrayAltText())
{
// applying for "dc:description", "dc:rights", "dc:title"
repairAltText(currProp);
}
}
}
}
/**
* Make sure that the array is well-formed AltText. Each item must be simple
* and have an "xml:lang" qualifier. If repairs are needed, keep simple
* non-empty items by adding the "xml:lang" with value "x-repair".
* @param arrayNode the property node of the array to repair.
* @throws XMPException Forwards unexpected exceptions.
*/
private static void repairAltText(XMPNode arrayNode) throws XMPException
{
if (arrayNode == null ||
!arrayNode.getOptions().isArray())
{
// Already OK or not even an array.
return;
}
// fix options
arrayNode.getOptions().setArrayOrdered(true).setArrayAlternate(true).setArrayAltText(true);
for (Iterator it = arrayNode.iterateChildren(); it.hasNext();)
{
XMPNode currChild = (XMPNode) it.next();
if (currChild.getOptions().isCompositeProperty())
{
// Delete non-simple children.
it.remove();
}
else if (!currChild.getOptions().getHasLanguage())
{
String childValue = currChild.getValue();
if (childValue == null || childValue.length() == 0)
{
// Delete empty valued children that have no xml:lang.
it.remove();
}
else
{
// Add an xml:lang qualifier with the value "x-repair".
XMPNode repairLang = new XMPNode(XMPConst.XML_LANG, "x-repair", null);
currChild.addQualifier(repairLang);
}
}
}
}
/**
* Visit all of the top level nodes looking for aliases. If there is
* no base, transplant the alias subtree. If there is a base and strict
* aliasing is on, make sure the alias and base subtrees match.
*
* @param tree the root of the metadata tree
* @param options th parsing options
* @throws XMPException Forwards XMP errors
*/
private static void moveExplicitAliases(XMPNode tree, ParseOptions options)
throws XMPException
{
if (!tree.getHasAliases())
{
return;
}
tree.setHasAliases(false);
boolean strictAliasing = options.getStrictAliasing();
for (Iterator schemaIt = tree.getUnmodifiableChildren().iterator(); schemaIt.hasNext();)
{
XMPNode currSchema = (XMPNode) schemaIt.next();
if (!currSchema.getHasAliases())
{
continue;
}
for (Iterator propertyIt = currSchema.iterateChildren(); propertyIt.hasNext();)
{
XMPNode currProp = (XMPNode) propertyIt.next();
if (!currProp.isAlias())
{
continue;
}
currProp.setAlias(false);
// Find the base path, look for the base schema and root node.
XMPAliasInfo info = XMPMetaFactory.getSchemaRegistry()
.findAlias(currProp.getName());
if (info != null)
{
// find or create schema
XMPNode baseSchema = XMPNodeUtils.findSchemaNode(tree, info
.getNamespace(), null, true);
baseSchema.setImplicit(false);
XMPNode baseNode = XMPNodeUtils
.findChildNode(baseSchema,
info.getPrefix() + info.getPropName(), false);
if (baseNode == null)
{
if (info.getAliasForm().isSimple())
{
// A top-to-top alias, transplant the property.
// change the alias property name to the base name
String qname = info.getPrefix() + info.getPropName();
currProp.setName(qname);
baseSchema.addChild(currProp);
// remove the alias property
propertyIt.remove();
}
else
{
// An alias to an array item,
// create the array and transplant the property.
baseNode = new XMPNode(info.getPrefix() + info.getPropName(), info
.getAliasForm().toPropertyOptions());
baseSchema.addChild(baseNode);
transplantArrayItemAlias (propertyIt, currProp, baseNode);
}
}
else if (info.getAliasForm().isSimple())
{
// The base node does exist and this is a top-to-top alias.
// Check for conflicts if strict aliasing is on.
// Remove and delete the alias subtree.
if (strictAliasing)
{
compareAliasedSubtrees (currProp, baseNode, true);
}
propertyIt.remove();
}
else
{
// This is an alias to an array item and the array exists.
// Look for the aliased item.
// Then transplant or check & delete as appropriate.
XMPNode itemNode = null;
if (info.getAliasForm().isArrayAltText())
{
int xdIndex = XMPNodeUtils.lookupLanguageItem(baseNode,
XMPConst.X_DEFAULT);
if (xdIndex != -1)
{
itemNode = baseNode.getChild(xdIndex);
}
}
else if (baseNode.hasChildren())
{
itemNode = baseNode.getChild(1);
}
if (itemNode == null)
{
transplantArrayItemAlias (propertyIt, currProp, baseNode);
}
else
{
if (strictAliasing)
{
compareAliasedSubtrees (currProp, itemNode, true);
}
propertyIt.remove();
}
}
}
}
currSchema.setHasAliases(false);
}
}
/**
* Moves an alias node of array form to another schema into an array
* @param propertyIt the property iterator of the old schema (used to delete the property)
* @param childNode the node to be moved
* @param baseArray the base array for the array item
* @throws XMPException Forwards XMP errors
*/
private static void transplantArrayItemAlias(Iterator propertyIt, XMPNode childNode,
XMPNode baseArray) throws XMPException
{
if (baseArray.getOptions().isArrayAltText())
{
if (childNode.getOptions().getHasLanguage())
{
throw new XMPException("Alias to x-default already has a language qualifier",
XMPError.BADXMP);
}
XMPNode langQual = new XMPNode(XMPConst.XML_LANG, XMPConst.X_DEFAULT, null);
childNode.addQualifier(langQual);
}
propertyIt.remove();
childNode.setName(XMPConst.ARRAY_ITEM_NAME);
baseArray.addChild(childNode);
}
/**
* Fixes the GPS Timestamp in EXIF.
* @param exifSchema the EXIF schema node
* @throws XMPException Thrown if the date conversion fails.
*/
private static void fixGPSTimeStamp(XMPNode exifSchema)
throws XMPException
{
// Note: if dates are not found the convert-methods throws an exceptions,
// and this methods returns.
XMPNode gpsDateTime = XMPNodeUtils.findChildNode(exifSchema, "exif:GPSTimeStamp", false);
if (gpsDateTime == null)
{
return;
}
try
{
XMPDateTime binGPSStamp;
XMPDateTime binOtherDate;
binGPSStamp = XMPUtils.convertToDate(gpsDateTime.getValue());
if (binGPSStamp.getYear() != 0 ||
binGPSStamp.getMonth() != 0 ||
binGPSStamp.getDay() != 0)
{
return;
}
XMPNode otherDate = XMPNodeUtils.findChildNode(exifSchema, "exif:DateTimeOriginal",
false);
if (otherDate == null)
{
otherDate = XMPNodeUtils.findChildNode(exifSchema, "exif:DateTimeDigitized", false);
}
binOtherDate = XMPUtils.convertToDate(otherDate.getValue());
Calendar cal = binGPSStamp.getCalendar();
cal.set(Calendar.YEAR, binOtherDate.getYear());
cal.set(Calendar.MONTH, binOtherDate.getMonth());
cal.set(Calendar.DAY_OF_MONTH, binOtherDate.getDay());
binGPSStamp = new XMPDateTimeImpl(cal);
gpsDateTime.setValue(XMPUtils.convertFromDate (binGPSStamp));
}
catch (XMPException e)
{
// Don't let a missing or bad date stop other things.
return;
}
}
/**
* Remove all empty schemas from the metadata tree that were generated during the rdf parsing.
* @param tree the root of the metadata tree
*/
private static void deleteEmptySchemas(XMPNode tree)
{
// Delete empty schema nodes. Do this last, other cleanup can make empty
// schema.
for (Iterator it = tree.iterateChildren(); it.hasNext();)
{
XMPNode schema = (XMPNode) it.next();
if (!schema.hasChildren())
{
it.remove();
}
}
}
/**
* The outermost call is special. The names almost certainly differ. The
* qualifiers (and hence options) will differ for an alias to the x-default
* item of a langAlt array.
*
* @param aliasNode the alias node
* @param baseNode the base node of the alias
* @param outerCall marks the outer call of the recursion
* @throws XMPException Forwards XMP errors
*/
private static void compareAliasedSubtrees(XMPNode aliasNode, XMPNode baseNode,
boolean outerCall) throws XMPException
{
if (!aliasNode.getValue().equals(baseNode.getValue()) ||
aliasNode.getChildrenLength() != baseNode.getChildrenLength())
{
throw new XMPException("Mismatch between alias and base nodes", XMPError.BADXMP);
}
if (
!outerCall &&
(!aliasNode.getName().equals(baseNode.getName()) ||
!aliasNode.getOptions().equals(baseNode.getOptions()) ||
aliasNode.getQualifierLength() != baseNode.getQualifierLength())
)
{
throw new XMPException("Mismatch between alias and base nodes",
XMPError.BADXMP);
}
for (Iterator an = aliasNode.iterateChildren(),
bn = baseNode.iterateChildren();
an.hasNext() && bn.hasNext();)
{
XMPNode aliasChild = (XMPNode) an.next();
XMPNode baseChild = (XMPNode) bn.next();
compareAliasedSubtrees (aliasChild, baseChild, false);
}
for (Iterator an = aliasNode.iterateQualifier(),
bn = baseNode.iterateQualifier();
an.hasNext() && bn.hasNext();)
{
XMPNode aliasQual = (XMPNode) an.next();
XMPNode baseQual = (XMPNode) bn.next();
compareAliasedSubtrees (aliasQual, baseQual, false);
}
}
/**
* The initial support for WAV files mapped a legacy ID3 audio copyright
* into a new xmpDM:copyright property. This is special case code to migrate
* that into dc:rights['x-default']. The rules:
*
* <pre>
* 1. If there is no dc:rights array, or an empty array -
* Create one with dc:rights['x-default'] set from double linefeed and xmpDM:copyright.
*
* 2. If there is a dc:rights array but it has no x-default item -
* Create an x-default item as a copy of the first item then apply rule #3.
*
* 3. If there is a dc:rights array with an x-default item,
* Look for a double linefeed in the value.
* A. If no double linefeed, compare the x-default value to the xmpDM:copyright value.
* A1. If they match then leave the x-default value alone.
* A2. Otherwise, append a double linefeed and
* the xmpDM:copyright value to the x-default value.
* B. If there is a double linefeed, compare the trailing text to the xmpDM:copyright value.
* B1. If they match then leave the x-default value alone.
* B2. Otherwise, replace the trailing x-default text with the xmpDM:copyright value.
*
* 4. In all cases, delete the xmpDM:copyright property.
* </pre>
*
* @param xmp the metadata object
* @param dmCopyright the "dm:copyright"-property
*/
private static void migrateAudioCopyright (XMPMeta xmp, XMPNode dmCopyright)
{
try
{
XMPNode dcSchema = XMPNodeUtils.findSchemaNode(
((XMPMetaImpl) xmp).getRoot(), XMPConst.NS_DC, true);
String dmValue = dmCopyright.getValue();
String doubleLF = "\n\n";
XMPNode dcRightsArray = XMPNodeUtils.findChildNode (dcSchema, "dc:rights", false);
if (dcRightsArray == null || !dcRightsArray.hasChildren())
{
// 1. No dc:rights array, create from double linefeed and xmpDM:copyright.
dmValue = doubleLF + dmValue;
xmp.setLocalizedText(XMPConst.NS_DC, "rights", "", XMPConst.X_DEFAULT, dmValue,
null);
}
else
{
int xdIndex = XMPNodeUtils.lookupLanguageItem(dcRightsArray, XMPConst.X_DEFAULT);
if (xdIndex < 0)
{
// 2. No x-default item, create from the first item.
String firstValue = dcRightsArray.getChild(1).getValue();
xmp.setLocalizedText (XMPConst.NS_DC, "rights", "", XMPConst.X_DEFAULT,
firstValue, null);
xdIndex = XMPNodeUtils.lookupLanguageItem(dcRightsArray, XMPConst.X_DEFAULT);
}
// 3. Look for a double linefeed in the x-default value.
XMPNode defaultNode = dcRightsArray.getChild(xdIndex);
String defaultValue = defaultNode.getValue();
int lfPos = defaultValue.indexOf(doubleLF);
if (lfPos < 0)
{
// 3A. No double LF, compare whole values.
if (!dmValue.equals(defaultValue))
{
// 3A2. Append the xmpDM:copyright to the x-default
// item.
defaultNode.setValue(defaultValue + doubleLF + dmValue);
}
}
else
{
// 3B. Has double LF, compare the tail.
if (!defaultValue.substring(lfPos + 2).equals(dmValue))
{
// 3B2. Replace the x-default tail.
defaultNode.setValue(defaultValue.substring(0, lfPos + 2) + dmValue);
}
}
}
// 4. Get rid of the xmpDM:copyright.
dmCopyright.getParent().removeChild(dmCopyright);
}
catch (XMPException e)
{
// Don't let failures (like a bad dc:rights form) stop other
// cleanup.
}
}
/**
* Initializes the map that contains the known arrays, that are fixed by
* {@link XMPNormalizer#normalizeDCArrays(XMPNode)}.
*/
private static void initDCArrays()
{
dcArrayForms = new HashMap();
// Properties supposed to be a "Bag".
PropertyOptions bagForm = new PropertyOptions();
bagForm.setArray(true);
dcArrayForms.put("dc:contributor", bagForm);
dcArrayForms.put("dc:language", bagForm);
dcArrayForms.put("dc:publisher", bagForm);
dcArrayForms.put("dc:relation", bagForm);
dcArrayForms.put("dc:subject", bagForm);
dcArrayForms.put("dc:type", bagForm);
// Properties supposed to be a "Seq".
PropertyOptions seqForm = new PropertyOptions();
seqForm.setArray(true);
seqForm.setArrayOrdered(true);
dcArrayForms.put("dc:creator", seqForm);
dcArrayForms.put("dc:date", seqForm);
// Properties supposed to be an "Alt" in alternative-text form.
PropertyOptions altTextForm = new PropertyOptions();
altTextForm.setArray(true);
altTextForm.setArrayOrdered(true);
altTextForm.setArrayAlternate(true);
altTextForm.setArrayAltText(true);
dcArrayForms.put("dc:description", altTextForm);
dcArrayForms.put("dc:rights", altTextForm);
dcArrayForms.put("dc:title", altTextForm);
}
}