Index: /trunk/CONTRIBUTION
===================================================================
--- /trunk/CONTRIBUTION	(revision 4230)
+++ /trunk/CONTRIBUTION	(revision 4231)
@@ -28,5 +28,6 @@
 
 The jpeg metadata extraction code is from Drew Noakes
-(http://drewnoakes.com/code/exif).
+(http://code.google.com/p/metadata-extractor/) and licensed
+with Apache license version 2.0.
 
 Most icons have been nicked from GNOME and the GIMP, both
Index: /trunk/build.xml
===================================================================
--- /trunk/build.xml	(revision 4230)
+++ /trunk/build.xml	(revision 4231)
@@ -88,6 +88,6 @@
             <zipfileset dir="src/org/openstreetmap/gui/jmapviewer/images" prefix="org/openstreetmap/gui/jmapviewer/images"/>
             <!-- All jar files necessary to run only JOSM (no tests) -->
-            <zipfileset src="lib/metadata-extractor-2.3.1-nosun.jar"/>
-            <zipfileset src="lib/signpost-core-1.2.1.1.jar"/>
+            <!-- <zipfileset src="lib/metadata-extractor-2.3.1-nosun.jar"/>  -->
+            <!-- <zipfileset src="lib/signpost-core-1.2.1.1.jar"/> -->
         </jar>
     </target>
@@ -106,5 +106,6 @@
     </target>
     <target name="compile" depends="init">
-        <javac srcdir="src" classpathref="classpath" destdir="build" target="1.5" source="1.5" debug="on" encoding="UTF-8">
+        <javac srcdir="src" classpathref="classpath" destdir="build" target="1.5" source="1.5" debug="on" encoding="UTF-8"
+               excludes="com/drew/metadata/test/,com/drew/metadata/jpeg/test/,com/drew/metadata/iptc/test/">
             <compilerarg value="-Xlint:deprecation"/>
             <compilerarg value="-Xlint:unchecked"/>
Index: /trunk/src/com/drew/imaging/PhotographicConversions.java
===================================================================
--- /trunk/src/com/drew/imaging/PhotographicConversions.java	(revision 4231)
+++ /trunk/src/com/drew/imaging/PhotographicConversions.java	(revision 4231)
@@ -0,0 +1,51 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.imaging;
+
+/**
+ * Contains helper methods that perform photographic conversions.
+ */
+public class PhotographicConversions
+{
+    public final static double ROOT_TWO = Math.sqrt(2);
+
+    private PhotographicConversions()
+    {}
+
+    /**
+     * Converts an aperture value to its corresponding F-stop number.
+     * @param aperture the aperture value to convert
+     * @return the F-stop number of the specified aperture
+     */
+    public static double apertureToFStop(double aperture)
+    {
+        double fStop = Math.pow(ROOT_TWO, aperture);
+        return fStop;
+
+        // Puzzle?!
+        // jhead uses a different calculation as far as i can tell...  this confuses me...
+        // fStop = (float)Math.exp(aperture * Math.log(2) * 0.5));
+    }
+
+    /**
+     * Converts a shutter speed to an exposure time.
+     * @param shutterSpeed the shutter speed to convert
+     * @return the exposure time of the specified shutter speed
+     */
+    public static double shutterSpeedToExposureTime(double shutterSpeed)
+    {
+        return (float)(1 / Math.exp(shutterSpeed * Math.log(2)));
+    }
+}
Index: /trunk/src/com/drew/imaging/jpeg/JpegMetadataReader.java
===================================================================
--- /trunk/src/com/drew/imaging/jpeg/JpegMetadataReader.java	(revision 4231)
+++ /trunk/src/com/drew/imaging/jpeg/JpegMetadataReader.java	(revision 4231)
@@ -0,0 +1,141 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 12-Nov-2002 18:51:36 using IntelliJ IDEA.
+ */
+package com.drew.imaging.jpeg;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.Metadata;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.Tag;
+import com.drew.metadata.exif.ExifDirectory;
+import com.drew.metadata.exif.ExifReader;
+import com.drew.metadata.iptc.IptcReader;
+import com.drew.metadata.jpeg.JpegCommentReader;
+import com.drew.metadata.jpeg.JpegReader;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Iterator;
+
+/**
+ *
+ */
+public class JpegMetadataReader
+{
+//    public static Metadata readMetadata(IIOMetadata metadata) throws JpegProcessingException {}
+//    public static Metadata readMetadata(ImageInputStream in) throws JpegProcessingException{}
+//    public static Metadata readMetadata(IIOImage image) throws JpegProcessingException{}
+//    public static Metadata readMetadata(ImageReader reader) throws JpegProcessingException{}
+
+    public static Metadata readMetadata(InputStream in) throws JpegProcessingException
+    {
+        JpegSegmentReader segmentReader = new JpegSegmentReader(in);
+        return extractMetadataFromJpegSegmentReader(segmentReader);
+    }
+
+    public static Metadata readMetadata(File file) throws JpegProcessingException
+    {
+        JpegSegmentReader segmentReader = new JpegSegmentReader(file);
+        return extractMetadataFromJpegSegmentReader(segmentReader);
+    }
+
+    public static Metadata extractMetadataFromJpegSegmentReader(JpegSegmentReader segmentReader)
+    {
+        final Metadata metadata = new Metadata();
+        try {
+            byte[] exifSegment = segmentReader.readSegment(JpegSegmentReader.SEGMENT_APP1);
+            new ExifReader(exifSegment).extract(metadata);
+        } catch (JpegProcessingException e) {
+            // in the interests of catching as much data as possible, continue
+            // TODO lodge error message within exif directory?
+        }
+
+        try {
+            byte[] iptcSegment = segmentReader.readSegment(JpegSegmentReader.SEGMENT_APPD);
+            new IptcReader(iptcSegment).extract(metadata);
+        } catch (JpegProcessingException e) {
+            // TODO lodge error message within iptc directory?
+        }
+
+		try {
+			byte[] jpegSegment = segmentReader.readSegment(JpegSegmentReader.SEGMENT_SOF0);
+			new JpegReader(jpegSegment).extract(metadata);
+		} catch (JpegProcessingException e) {
+			// TODO lodge error message within jpeg directory?
+		}
+
+		try {
+			byte[] jpegCommentSegment = segmentReader.readSegment(JpegSegmentReader.SEGMENT_COM);
+			new JpegCommentReader(jpegCommentSegment).extract(metadata);
+		} catch (JpegProcessingException e) {
+			// TODO lodge error message within jpegcomment directory?
+		}
+
+        return metadata;
+    }
+
+    private JpegMetadataReader()
+    {
+    }
+
+    public static void main(String[] args) throws MetadataException, IOException
+    {
+        Metadata metadata = null;
+        try {
+            metadata = JpegMetadataReader.readMetadata(new File(args[0]));
+        } catch (Exception e) {
+            e.printStackTrace(System.err);
+            System.exit(1);
+        }
+
+        // iterate over the exif data and print to System.out
+        Iterator directories = metadata.getDirectoryIterator();
+        while (directories.hasNext()) {
+            Directory directory = (Directory)directories.next();
+            Iterator tags = directory.getTagIterator();
+            while (tags.hasNext()) {
+                Tag tag = (Tag)tags.next();
+                try {
+                    System.out.println("[" + directory.getName() + "] " + tag.getTagName() + " = " + tag.getDescription());
+                } catch (MetadataException e) {
+                    System.err.println(e.getMessage());
+                    System.err.println(tag.getDirectoryName() + " " + tag.getTagName() + " (error)");
+                }
+            }
+            if (directory.hasErrors()) {
+                Iterator errors = directory.getErrors();
+                while (errors.hasNext()) {
+                    System.out.println("ERROR: " + errors.next());
+                }
+            }
+        }
+
+        if (args.length>1 && args[1].trim().equals("/thumb"))
+        {
+            ExifDirectory directory = (ExifDirectory)metadata.getDirectory(ExifDirectory.class);
+            if (directory.containsThumbnail())
+            {
+                System.out.println("Writing thumbnail...");
+                directory.writeThumbnail(args[0].trim() + ".thumb.jpg");
+            }
+            else
+            {
+                System.out.println("No thumbnail data exists in this image");
+            }
+        }
+    }
+}
Index: /trunk/src/com/drew/imaging/jpeg/JpegProcessingException.java
===================================================================
--- /trunk/src/com/drew/imaging/jpeg/JpegProcessingException.java	(revision 4231)
+++ /trunk/src/com/drew/imaging/jpeg/JpegProcessingException.java	(revision 4231)
@@ -0,0 +1,44 @@
+/*
+ * JpegProcessingException.java
+ *
+ * This class is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 04-Nov-2002 19:31:29 using IntelliJ IDEA.
+ */
+package com.drew.imaging.jpeg;
+
+import com.drew.lang.CompoundException;
+
+/**
+ * An exception class thrown upon unexpected and fatal conditions while processing
+ * a Jpeg file.
+ * @author  Drew Noakes http://drewnoakes.com
+ */
+public class JpegProcessingException extends CompoundException
+{
+    public JpegProcessingException(String message)
+    {
+        super(message);
+    }
+
+    public JpegProcessingException(String message, Throwable cause)
+    {
+        super(message, cause);
+    }
+
+    public JpegProcessingException(Throwable cause)
+    {
+        super(cause);
+    }
+}
Index: /trunk/src/com/drew/imaging/jpeg/JpegSegmentData.java
===================================================================
--- /trunk/src/com/drew/imaging/jpeg/JpegSegmentData.java	(revision 4231)
+++ /trunk/src/com/drew/imaging/jpeg/JpegSegmentData.java	(revision 4231)
@@ -0,0 +1,132 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.imaging.jpeg;
+
+import java.io.*;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+/**
+ * Holds a collection of Jpeg data segments.  This need not necessarily be all segments
+ * within the Jpeg.  For example, it may be convenient to port about only the non-image
+ * segments when analysing (or serializing) metadata.
+ */
+public class JpegSegmentData implements Serializable
+{
+    static final long serialVersionUID = 7110175216435025451L;
+    
+    /** A map of byte[], keyed by the segment marker */
+    private final HashMap _segmentDataMap;
+
+    public JpegSegmentData()
+    {
+        _segmentDataMap = new HashMap(10);
+    }
+
+    public void addSegment(byte segmentMarker, byte[] segmentBytes)
+    {
+        List segmentList = getOrCreateSegmentList(segmentMarker);
+        segmentList.add(segmentBytes);
+    }
+
+    public byte[] getSegment(byte segmentMarker)
+    {
+        return getSegment(segmentMarker, 0);
+    }
+
+    public byte[] getSegment(byte segmentMarker, int occurrence)
+    {
+        final List segmentList = getSegmentList(segmentMarker);
+
+        if (segmentList==null || segmentList.size()<=occurrence)
+            return null;
+        else
+            return (byte[]) segmentList.get(occurrence);
+    }
+
+    public int getSegmentCount(byte segmentMarker)
+    {
+        final List segmentList = getSegmentList(segmentMarker);
+        if (segmentList==null)
+            return 0;
+        else
+            return segmentList.size();
+    }
+
+    public void removeSegmentOccurrence(byte segmentMarker, int occurrence)
+    {
+        final List segmentList = (List)_segmentDataMap.get(new Byte(segmentMarker));
+        segmentList.remove(occurrence);
+    }
+
+    public void removeSegment(byte segmentMarker)
+    {
+        _segmentDataMap.remove(new Byte(segmentMarker));
+    }
+
+    private List getSegmentList(byte segmentMarker)
+    {
+        return (List)_segmentDataMap.get(new Byte(segmentMarker));
+    }
+
+    private List getOrCreateSegmentList(byte segmentMarker)
+    {
+        List segmentList;
+        Byte key = new Byte(segmentMarker);
+        if (_segmentDataMap.containsKey(key)) {
+            segmentList = (List)_segmentDataMap.get(key);
+        } else {
+            segmentList = new ArrayList();
+            _segmentDataMap.put(key, segmentList);
+        }
+        return segmentList;
+    }
+
+    public boolean containsSegment(byte segmentMarker)
+    {
+        return _segmentDataMap.containsKey(new Byte(segmentMarker));
+    }
+
+    public static void ToFile(File file, JpegSegmentData segmentData) throws IOException
+    {
+        ObjectOutputStream outputStream = null;
+        try
+        {
+            outputStream = new ObjectOutputStream(new FileOutputStream(file));
+            outputStream.writeObject(segmentData);
+        }
+        finally
+        {
+            if (outputStream!=null)
+                outputStream.close();
+        }
+    }
+
+    public static JpegSegmentData FromFile(File file) throws IOException, ClassNotFoundException
+    {
+        ObjectInputStream inputStream = null;
+        try
+        {
+            inputStream = new ObjectInputStream(new FileInputStream(file));
+            return (JpegSegmentData)inputStream.readObject();
+        }
+        finally
+        {
+            if (inputStream!=null)
+                inputStream.close();
+        }
+    }
+}
Index: /trunk/src/com/drew/imaging/jpeg/JpegSegmentReader.java
===================================================================
--- /trunk/src/com/drew/imaging/jpeg/JpegSegmentReader.java	(revision 4231)
+++ /trunk/src/com/drew/imaging/jpeg/JpegSegmentReader.java	(revision 4231)
@@ -0,0 +1,283 @@
+/*
+ * JpegSegmentReader.java
+ *
+ * This class written by Drew Noakes, in accordance with the Jpeg specification.
+ *
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 04-Nov-2002 00:54:00 using IntelliJ IDEA
+ */
+package com.drew.imaging.jpeg;
+
+import java.io.*;
+
+/**
+ * Performs read functions of Jpeg files, returning specific file segments.
+ * TODO add a findAvailableSegments() method
+ * TODO add more segment identifiers
+ * TODO add a getSegmentDescription() method, returning for example 'App1 application data segment, commonly containing Exif data'
+ * @author  Drew Noakes http://drewnoakes.com
+ */
+public class JpegSegmentReader
+{
+    // Jpeg data can be sourced from either a file, byte[] or InputStream
+
+    /** Jpeg file */
+    private final File _file;
+    /** Jpeg data as byte array */
+    private final byte[] _data;
+    /** Jpeg data as an InputStream */
+    private final InputStream _stream;
+
+    private JpegSegmentData _segmentData;
+
+    /**
+     * Private, because this segment crashes my algorithm, and searching for
+     * it doesn't work (yet).
+     */
+    private static final byte SEGMENT_SOS = (byte)0xDA;
+
+    /**
+     * Private, because one wouldn't search for it.
+     */
+    private static final byte MARKER_EOI = (byte)0xD9;
+
+    /** APP0 Jpeg segment identifier -- Jfif data. */
+    public static final byte SEGMENT_APP0 = (byte)0xE0;
+    /** APP1 Jpeg segment identifier -- where Exif data is kept. */
+    public static final byte SEGMENT_APP1 = (byte)0xE1;
+    /** APP2 Jpeg segment identifier. */
+    public static final byte SEGMENT_APP2 = (byte)0xE2;
+    /** APP3 Jpeg segment identifier. */
+    public static final byte SEGMENT_APP3 = (byte)0xE3;
+    /** APP4 Jpeg segment identifier. */
+    public static final byte SEGMENT_APP4 = (byte)0xE4;
+    /** APP5 Jpeg segment identifier. */
+    public static final byte SEGMENT_APP5 = (byte)0xE5;
+    /** APP6 Jpeg segment identifier. */
+    public static final byte SEGMENT_APP6 = (byte)0xE6;
+    /** APP7 Jpeg segment identifier. */
+    public static final byte SEGMENT_APP7 = (byte)0xE7;
+    /** APP8 Jpeg segment identifier. */
+    public static final byte SEGMENT_APP8 = (byte)0xE8;
+    /** APP9 Jpeg segment identifier. */
+    public static final byte SEGMENT_APP9 = (byte)0xE9;
+    /** APPA Jpeg segment identifier -- can hold Unicode comments. */
+    public static final byte SEGMENT_APPA = (byte)0xEA;
+    /** APPB Jpeg segment identifier. */
+    public static final byte SEGMENT_APPB = (byte)0xEB;
+    /** APPC Jpeg segment identifier. */
+    public static final byte SEGMENT_APPC = (byte)0xEC;
+    /** APPD Jpeg segment identifier -- IPTC data in here. */
+    public static final byte SEGMENT_APPD = (byte)0xED;
+    /** APPE Jpeg segment identifier. */
+    public static final byte SEGMENT_APPE = (byte)0xEE;
+    /** APPF Jpeg segment identifier. */
+    public static final byte SEGMENT_APPF = (byte)0xEF;
+    /** Start Of Image segment identifier. */
+    public static final byte SEGMENT_SOI = (byte)0xD8;
+    /** Define Quantization Table segment identifier. */
+    public static final byte SEGMENT_DQT = (byte)0xDB;
+    /** Define Huffman Table segment identifier. */
+    public static final byte SEGMENT_DHT = (byte)0xC4;
+    /** Start-of-Frame Zero segment identifier. */
+    public static final byte SEGMENT_SOF0 = (byte)0xC0;
+    /** Jpeg comment segment identifier. */
+    public static final byte SEGMENT_COM = (byte)0xFE;
+
+    /**
+     * Creates a JpegSegmentReader for a specific file.
+     * @param file the Jpeg file to read segments from
+     */
+    public JpegSegmentReader(File file) throws JpegProcessingException
+    {
+        _file = file;
+        _data = null;
+        _stream = null;
+
+        readSegments();
+    }
+
+    /**
+     * Creates a JpegSegmentReader for a byte array.
+     * @param fileContents the byte array containing Jpeg data
+     */
+    public JpegSegmentReader(byte[] fileContents) throws JpegProcessingException
+    {
+        _file = null;
+        _data = fileContents;
+        _stream = null;
+
+        readSegments();
+    }
+
+    public JpegSegmentReader(InputStream in) throws JpegProcessingException
+    {
+        _stream = in;
+        _file = null;
+        _data = null;
+        
+        readSegments();
+    }
+
+    public JpegSegmentReader(JpegSegmentData segmentData)
+    {
+        _file = null;
+        _data = null;
+        _stream = null;
+
+        _segmentData = segmentData;
+    }
+
+    /**
+     * Reads the first instance of a given Jpeg segment, returning the contents as
+     * a byte array.
+     * @param segmentMarker the byte identifier for the desired segment
+     * @return the byte array if found, else null
+     * @throws JpegProcessingException for any problems processing the Jpeg data,
+     *         including inner IOExceptions
+     */
+    public byte[] readSegment(byte segmentMarker) throws JpegProcessingException
+    {
+        return readSegment(segmentMarker, 0);
+    }
+
+    /**
+     * Reads the first instance of a given Jpeg segment, returning the contents as
+     * a byte array.
+     * @param segmentMarker the byte identifier for the desired segment
+     * @param occurrence the occurrence of the specified segment within the jpeg file
+     * @return the byte array if found, else null
+     */
+    public byte[] readSegment(byte segmentMarker, int occurrence)
+    {
+        return _segmentData.getSegment(segmentMarker, occurrence);
+    }
+
+    public final int getSegmentCount(byte segmentMarker)
+    {
+        return _segmentData.getSegmentCount(segmentMarker);
+    }
+
+    public final JpegSegmentData getSegmentData()
+    {
+        return _segmentData;
+    }
+
+    private void readSegments() throws JpegProcessingException
+    {
+        _segmentData = new JpegSegmentData();
+
+        BufferedInputStream inStream = getJpegInputStream();
+        try {
+            int offset = 0;
+            // first two bytes should be jpeg magic number
+            if (!isValidJpegHeaderBytes(inStream)) {
+                throw new JpegProcessingException("not a jpeg file");
+            }
+            offset += 2;
+            do {
+                // next byte is 0xFF
+                byte segmentIdentifier = (byte)(inStream.read() & 0xFF);
+                if ((segmentIdentifier & 0xFF) != 0xFF) {
+                    throw new JpegProcessingException("expected jpeg segment start identifier 0xFF at offset " + offset + ", not 0x" + Integer.toHexString(segmentIdentifier & 0xFF));
+                }
+                offset++;
+                // next byte is <segment-marker>
+                byte thisSegmentMarker = (byte)(inStream.read() & 0xFF);
+                offset++;
+                // next 2-bytes are <segment-size>: [high-byte] [low-byte]
+                byte[] segmentLengthBytes = new byte[2];
+                inStream.read(segmentLengthBytes, 0, 2);
+                offset += 2;
+                int segmentLength = ((segmentLengthBytes[0] << 8) & 0xFF00) | (segmentLengthBytes[1] & 0xFF);
+                // segment length includes size bytes, so subtract two
+                segmentLength -= 2;
+                if (segmentLength > inStream.available())
+                    throw new JpegProcessingException("segment size would extend beyond file stream length");
+                else if (segmentLength < 0)
+                    throw new JpegProcessingException("segment size would be less than zero");
+                byte[] segmentBytes = new byte[segmentLength];
+                inStream.read(segmentBytes, 0, segmentLength);
+                offset += segmentLength;
+                if ((thisSegmentMarker & 0xFF) == (SEGMENT_SOS & 0xFF)) {
+                    // The 'Start-Of-Scan' segment's length doesn't include the image data, instead would
+                    // have to search for the two bytes: 0xFF 0xD9 (EOI).
+                    // It comes last so simply return at this point
+                    return;
+                } else if ((thisSegmentMarker & 0xFF) == (MARKER_EOI & 0xFF)) {
+                    // the 'End-Of-Image' segment -- this should never be found in this fashion
+                    return;
+                } else {
+                    _segmentData.addSegment(thisSegmentMarker, segmentBytes);
+                }
+                // didn't find the one we're looking for, loop through to the next segment
+            } while (true);
+        } catch (IOException ioe) {
+            //throw new JpegProcessingException("IOException processing Jpeg file", ioe);
+            throw new JpegProcessingException("IOException processing Jpeg file: " + ioe.getMessage(), ioe);
+        } finally {
+            try {
+                if (inStream != null) {
+                    inStream.close();
+                }
+            } catch (IOException ioe) {
+                //throw new JpegProcessingException("IOException processing Jpeg file", ioe);
+                throw new JpegProcessingException("IOException processing Jpeg file: " + ioe.getMessage(), ioe);
+            }
+        }
+    }
+
+    /**
+     * Private helper method to create a BufferedInputStream of Jpeg data from whichever
+     * data source was specified upon construction of this instance.
+     * @return a a BufferedInputStream of Jpeg data
+     * @throws JpegProcessingException for any problems obtaining the stream
+     */
+    private BufferedInputStream getJpegInputStream() throws JpegProcessingException
+    {
+        if (_stream!=null) {
+            if (_stream instanceof BufferedInputStream) {
+                return (BufferedInputStream) _stream;
+            } else {
+                return new BufferedInputStream(_stream);
+            }
+        }
+        InputStream inputStream;
+        if (_data == null) {
+            try {
+                inputStream = new FileInputStream(_file);
+            } catch (FileNotFoundException e) {
+                throw new JpegProcessingException("Jpeg file does not exist", e);
+            }
+        } else {
+            inputStream = new ByteArrayInputStream(_data);
+        }
+        return new BufferedInputStream(inputStream);
+    }
+
+    /**
+     * Helper method that validates the Jpeg file's magic number.
+     * @param fileStream the InputStream to read bytes from, which must be positioned
+     *        at its start (i.e. no bytes read yet)
+     * @return true if the magic number is Jpeg (0xFFD8)
+     * @throws IOException for any problem in reading the file
+     */
+    private boolean isValidJpegHeaderBytes(InputStream fileStream) throws IOException
+    {
+        byte[] header = new byte[2];
+        fileStream.read(header, 0, 2);
+        return (header[0] & 0xFF) == 0xFF && (header[1] & 0xFF) == 0xD8;
+    }
+}
Index: /trunk/src/com/drew/lang/CompoundException.java
===================================================================
--- /trunk/src/com/drew/lang/CompoundException.java	(revision 4231)
+++ /trunk/src/com/drew/lang/CompoundException.java	(revision 4231)
@@ -0,0 +1,89 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.lang;
+
+import java.io.PrintStream;
+import java.io.PrintWriter;
+
+/**
+ * Represents a compound exception, as modelled in JDK 1.4, but
+ * unavailable in previous versions.  This class allows support
+ * of these previous JDK versions.
+ */
+public class CompoundException extends Exception
+{
+    private final Throwable _innnerException;
+
+    public CompoundException(String msg)
+    {
+        this(msg, null);
+    }
+
+    public CompoundException(Throwable exception)
+    {
+        this(null, exception);
+    }
+
+    public CompoundException(String msg, Throwable innerException)
+    {
+        super(msg);
+        _innnerException = innerException;
+    }
+
+    public Throwable getInnerException()
+    {
+        return _innnerException;
+    }
+
+    public String toString()
+    {
+        StringBuffer sbuffer = new StringBuffer();
+        sbuffer.append(super.toString());
+        if (_innnerException != null) {
+            sbuffer.append("\n");
+            sbuffer.append("--- inner exception ---");
+            sbuffer.append("\n");
+            sbuffer.append(_innnerException.toString());
+        }
+        return sbuffer.toString();
+    }
+
+    public void printStackTrace(PrintStream s)
+    {
+        super.printStackTrace(s);
+        if (_innnerException != null) {
+            s.println("--- inner exception ---");
+            _innnerException.printStackTrace(s);
+        }
+    }
+
+    public void printStackTrace(PrintWriter s)
+    {
+        super.printStackTrace(s);
+        if (_innnerException != null) {
+            s.println("--- inner exception ---");
+            _innnerException.printStackTrace(s);
+        }
+    }
+
+    public void printStackTrace()
+    {
+        super.printStackTrace();
+        if (_innnerException != null) {
+            System.err.println("--- inner exception ---");
+            _innnerException.printStackTrace();
+        }
+    }
+}
Index: /trunk/src/com/drew/lang/NullOutputStream.java
===================================================================
--- /trunk/src/com/drew/lang/NullOutputStream.java	(revision 4231)
+++ /trunk/src/com/drew/lang/NullOutputStream.java	(revision 4231)
@@ -0,0 +1,33 @@
+/**
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on Dec 15, 2002 3:30:59 PM using IntelliJ IDEA.
+ */
+package com.drew.lang;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+public class NullOutputStream extends OutputStream
+{
+    public NullOutputStream()
+    {
+        super();
+    }
+
+    public void write(int b) throws IOException
+    {
+        // do nothing
+    }
+}
Index: /trunk/src/com/drew/lang/Rational.java
===================================================================
--- /trunk/src/com/drew/lang/Rational.java	(revision 4231)
+++ /trunk/src/com/drew/lang/Rational.java	(revision 4231)
@@ -0,0 +1,291 @@
+/*
+ * Rational.java
+ *
+ * This class is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  Similarly, I release this Java version under the
+ * same license, though I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew.noakes@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created on 6 May 2002, 18:06
+ * Updated 26 Aug 2002 by Drew
+ * - Added toSimpleString() method, which returns a simplified and hopefully more
+ *   readable version of the Rational.  i.e. 2/10 -> 1/5, and 10/2 -> 5
+ * Modified 29 Oct 2002 (v1.2)
+ * - Improved toSimpleString() to factor more complex rational numbers into
+ *   a simpler form
+ *     i.e.
+ *       10/15 -> 2/3
+ * - toSimpleString() now accepts a boolean flag, 'allowDecimals' which will
+ *   display the rational number in decimal form if it fits within 5 digits
+ *     i.e.
+ *       3/4 -> 0.75 when allowDecimal == true
+ */
+
+package com.drew.lang;
+
+import java.io.Serializable;
+
+/**
+ * Immutable class for holding a rational number without loss of precision.  Provides
+ * a familiar representation via toString() in form <code>numerator/denominator</code>.
+ * <p>
+ * @author  Drew Noakes http://drewnoakes.com
+ */
+public class Rational extends java.lang.Number implements Serializable
+{
+    /**
+     * Holds the numerator.
+     */
+    private final int numerator;
+
+    /**
+     * Holds the denominator.
+     */
+    private final int denominator;
+
+    private int maxSimplificationCalculations = 1000;
+
+    /**
+     * Creates a new instance of Rational.  Rational objects are immutable, so
+     * once you've set your numerator and denominator values here, you're stuck
+     * with them!
+     */
+    public Rational(int numerator, int denominator)
+    {
+        this.numerator = numerator;
+        this.denominator = denominator;
+    }
+
+    /**
+     * Returns the value of the specified number as a <code>double</code>.
+     * This may involve rounding.
+     *
+     * @return  the numeric value represented by this object after conversion
+     *          to type <code>double</code>.
+     */
+    public double doubleValue()
+    {
+        return (double)numerator / (double)denominator;
+    }
+
+    /**
+     * Returns the value of the specified number as a <code>float</code>.
+     * This may involve rounding.
+     *
+     * @return  the numeric value represented by this object after conversion
+     *          to type <code>float</code>.
+     */
+    public float floatValue()
+    {
+        return (float)numerator / (float)denominator;
+    }
+
+    /**
+     * Returns the value of the specified number as a <code>byte</code>.
+     * This may involve rounding or truncation.  This implementation simply
+     * casts the result of <code>doubleValue()</code> to <code>byte</code>.
+     *
+     * @return  the numeric value represented by this object after conversion
+     *          to type <code>byte</code>.
+     */
+    public final byte byteValue()
+    {
+        return (byte)doubleValue();
+    }
+
+    /**
+     * Returns the value of the specified number as an <code>int</code>.
+     * This may involve rounding or truncation.  This implementation simply
+     * casts the result of <code>doubleValue()</code> to <code>int</code>.
+     *
+     * @return  the numeric value represented by this object after conversion
+     *          to type <code>int</code>.
+     */
+    public final int intValue()
+    {
+        return (int)doubleValue();
+    }
+
+    /**
+     * Returns the value of the specified number as a <code>long</code>.
+     * This may involve rounding or truncation.  This implementation simply
+     * casts the result of <code>doubleValue()</code> to <code>long</code>.
+     *
+     * @return  the numeric value represented by this object after conversion
+     *          to type <code>long</code>.
+     */
+    public final long longValue()
+    {
+        return (long)doubleValue();
+    }
+
+    /**
+     * Returns the value of the specified number as a <code>short</code>.
+     * This may involve rounding or truncation.  This implementation simply
+     * casts the result of <code>doubleValue()</code> to <code>short</code>.
+     *
+     * @return  the numeric value represented by this object after conversion
+     *          to type <code>short</code>.
+     */
+    public final short shortValue()
+    {
+        return (short)doubleValue();
+    }
+
+
+    /**
+     * Returns the denominator.
+     */
+    public final int getDenominator()
+    {
+        return this.denominator;
+    }
+
+    /**
+     * Returns the numerator.
+     */
+    public final int getNumerator()
+    {
+        return this.numerator;
+    }
+
+    /**
+     * Returns the reciprocal value of this obejct as a new Rational.
+     * @return the reciprocal in a new object
+     */
+    public Rational getReciprocal()
+    {
+        return new Rational(this.denominator, this.numerator);
+    }
+
+    /**
+     * Checks if this rational number is an Integer, either positive or negative.
+     */
+    public boolean isInteger()
+    {
+        if (denominator == 1 ||
+                (denominator != 0 && (numerator % denominator == 0)) ||
+                (denominator == 0 && numerator == 0)
+        ) {
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Returns a string representation of the object of form <code>numerator/denominator</code>.
+     * @return  a string representation of the object.
+     */
+    public String toString()
+    {
+        return numerator + "/" + denominator;
+    }
+
+    /**
+     * Returns the simplest represenation of this Rational's value possible.
+     */
+    public String toSimpleString(boolean allowDecimal)
+    {
+        if (denominator == 0 && numerator != 0) {
+            return toString();
+        } else if (isInteger()) {
+            return Integer.toString(intValue());
+        } else if (numerator != 1 && denominator % numerator == 0) {
+            // common factor between denominator and numerator
+            int newDenominator = denominator / numerator;
+            return new Rational(1, newDenominator).toSimpleString(allowDecimal);
+        } else {
+            Rational simplifiedInstance = getSimplifiedInstance();
+            if (allowDecimal) {
+                String doubleString = Double.toString(simplifiedInstance.doubleValue());
+                if (doubleString.length() < 5) {
+                    return doubleString;
+                }
+            }
+            return simplifiedInstance.toString();
+        }
+    }
+
+    /**
+     * Decides whether a brute-force simplification calculation should be avoided
+     * by comparing the maximum number of possible calculations with some threshold.
+     * @return true if the simplification should be performed, otherwise false
+     */
+    private boolean tooComplexForSimplification()
+    {
+        double maxPossibleCalculations = (((double)(Math.min(denominator, numerator) - 1) / 5d) + 2);
+        return maxPossibleCalculations > maxSimplificationCalculations;
+    }
+
+    /**
+     * Compares two <code>Rational</code> instances, returning true if they are mathematically
+     * equivalent.
+     * @param obj the Rational to compare this instance to.
+     * @return true if instances are mathematically equivalent, otherwise false.  Will also
+     *         return false if <code>obj</code> is not an instance of <code>Rational</code>.
+     */
+    public boolean equals(Object obj)
+    {
+        if (!(obj instanceof Rational)) {
+            return false;
+        }
+        Rational that = (Rational)obj;
+        return this.doubleValue() == that.doubleValue();
+    }
+
+    /**
+     * <p>
+     * Simplifies the Rational number.</p>
+     * <p>
+     * Prime number series: 1, 2, 3, 5, 7, 9, 11, 13, 17</p>
+     * <p>
+     * To reduce a rational, need to see if both numerator and denominator are divisible
+     * by a common factor.  Using the prime number series in ascending order guarantees
+     * the minimun number of checks required.</p>
+     * <p>
+     * However, generating the prime number series seems to be a hefty task.  Perhaps
+     * it's simpler to check if both d & n are divisible by all numbers from 2 ->
+     * (Math.min(denominator, numerator) / 2).  In doing this, one can check for 2
+     * and 5 once, then ignore all even numbers, and all numbers ending in 0 or 5.
+     * This leaves four numbers from every ten to check.</p>
+     * <p>
+     * Therefore, the max number of pairs of modulus divisions required will be:</p>
+     * <code><pre>
+     *    4   Math.min(denominator, numerator) - 1
+     *   -- * ------------------------------------ + 2
+     *   10                    2
+     *
+     *   Math.min(denominator, numerator) - 1
+     * = ------------------------------------ + 2
+     *                  5
+     * </pre></code>
+     * @return a simplified instance, or if the Rational could not be simpliffied,
+     *         returns itself (unchanged)
+     */
+    public Rational getSimplifiedInstance()
+    {
+        if (tooComplexForSimplification()) {
+            return this;
+        }
+        for (int factor = 2; factor <= Math.min(denominator, numerator); factor++) {
+            if ((factor % 2 == 0 && factor > 2) || (factor % 5 == 0 && factor > 5)) {
+                continue;
+            }
+            if (denominator % factor == 0 && numerator % factor == 0) {
+                // found a common factor
+                return new Rational(numerator / factor, denominator / factor);
+            }
+        }
+        return this;
+    }
+}
Index: /trunk/src/com/drew/metadata/DefaultTagDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/DefaultTagDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/DefaultTagDescriptor.java	(revision 4231)
@@ -0,0 +1,40 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 22-Nov-2002 16:45:19 using IntelliJ IDEA.
+ */
+package com.drew.metadata;
+
+/**
+ *
+ */
+public class DefaultTagDescriptor extends TagDescriptor
+{
+    public DefaultTagDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getTagName(int tagType)
+    {
+        String hex = Integer.toHexString(tagType).toUpperCase();
+        while (hex.length() < 4) hex = "0" + hex;
+        return "Unknown tag 0x" + hex;
+    }
+
+    public String getDescription(int tagType)
+    {
+        return _directory.getString(tagType);
+    }
+}
Index: /trunk/src/com/drew/metadata/Directory.java
===================================================================
--- /trunk/src/com/drew/metadata/Directory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/Directory.java	(revision 4231)
@@ -0,0 +1,689 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 25-Nov-2002 20:30:39 using IntelliJ IDEA.
+ */
+package com.drew.metadata;
+
+import com.drew.lang.Rational;
+
+import java.io.Serializable;
+import java.lang.reflect.Array;
+import java.text.DateFormat;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Base class for all Metadata directory types with supporting methods for setting and
+ * getting tag values.
+ */
+public abstract class Directory implements Serializable
+{
+    /**
+     * Map of values hashed by type identifiers.
+     */
+    protected final HashMap _tagMap;
+
+    /**
+     * The descriptor used to interperet tag values.
+     */
+    protected TagDescriptor _descriptor;
+
+    /**
+     * A convenient list holding tag values in the order in which they were stored.
+     * This is used for creation of an iterator, and for counting the number of
+     * defined tags.
+     */
+    protected final List _definedTagList;
+
+    private List _errorList;
+
+// ABSTRACT METHODS
+
+    /**
+     * Provides the name of the directory, for display purposes.  E.g. <code>Exif</code>
+     * @return the name of the directory
+     */
+    public abstract String getName();
+
+    /**
+     * Provides the map of tag names, hashed by tag type identifier.
+     * @return the map of tag names
+     */
+    protected abstract HashMap getTagNameMap();
+
+// CONSTRUCTORS
+
+    /**
+     * Creates a new Directory.
+     */
+    public Directory()
+    {
+        _tagMap = new HashMap();
+        _definedTagList = new ArrayList();
+    }
+
+// VARIOUS METHODS
+
+    /**
+     * Indicates whether the specified tag type has been set.
+     * @param tagType the tag type to check for
+     * @return true if a value exists for the specified tag type, false if not
+     */
+    public boolean containsTag(int tagType)
+    {
+        return _tagMap.containsKey(new Integer(tagType));
+    }
+
+    /**
+     * Returns an Iterator of Tag instances that have been set in this Directory.
+     * @return an Iterator of Tag instances
+     */
+    public Iterator getTagIterator()
+    {
+        return _definedTagList.iterator();
+    }
+
+    /**
+     * Returns the number of tags set in this Directory.
+     * @return the number of tags set in this Directory
+     */
+    public int getTagCount()
+    {
+        return _definedTagList.size();
+    }
+
+    /**
+     * Sets the descriptor used to interperet tag values.
+     * @param descriptor the descriptor used to interperet tag values
+     */
+    public void setDescriptor(TagDescriptor descriptor)
+    {
+        if (descriptor==null) {
+            throw new NullPointerException("cannot set a null descriptor");
+        }
+        _descriptor = descriptor;
+    }
+
+    public void addError(String message)
+    {
+        if (_errorList==null) {
+            _errorList = new ArrayList();
+        }
+        _errorList.add(message);
+    }
+
+    public boolean hasErrors()
+    {
+        return (_errorList!=null && _errorList.size()>0);
+    }
+
+    public Iterator getErrors()
+    {
+        return _errorList.iterator();
+    }
+
+    public int getErrorCount()
+    {
+        return _errorList.size();
+    }
+
+// TAG SETTERS
+
+    /**
+     * Sets an int value for the specified tag.
+     * @param tagType the tag's value as an int
+     * @param value the value for the specified tag as an int
+     */
+    public void setInt(int tagType, int value)
+    {
+        setObject(tagType, new Integer(value));
+    }
+
+    /**
+     * Sets a double value for the specified tag.
+     * @param tagType the tag's value as an int
+     * @param value the value for the specified tag as a double
+     */
+    public void setDouble(int tagType, double value)
+    {
+        setObject(tagType, new Double(value));
+    }
+
+    /**
+     * Sets a float value for the specified tag.
+     * @param tagType the tag's value as an int
+     * @param value the value for the specified tag as a float
+     */
+    public void setFloat(int tagType, float value)
+    {
+        setObject(tagType, new Float(value));
+    }
+
+    /**
+     * Sets an int value for the specified tag.
+     * @param tagType the tag's value as an int
+     * @param value the value for the specified tag as a String
+     */
+    public void setString(int tagType, String value)
+    {
+        setObject(tagType, value);
+    }
+
+    /**
+     * Sets an int value for the specified tag.
+     * @param tagType the tag's value as an int
+     * @param value the value for the specified tag as a boolean
+     */
+    public void setBoolean(int tagType, boolean value)
+    {
+        setObject(tagType, new Boolean(value));
+    }
+
+    /**
+     * Sets a long value for the specified tag.
+     * @param tagType the tag's value as an int
+     * @param value the value for the specified tag as a long
+     */
+    public void setLong(int tagType, long value)
+    {
+        setObject(tagType, new Long(value));
+    }
+
+    /**
+     * Sets a java.util.Date value for the specified tag.
+     * @param tagType the tag's value as an int
+     * @param value the value for the specified tag as a java.util.Date
+     */
+    public void setDate(int tagType, java.util.Date value)
+    {
+        setObject(tagType, value);
+    }
+
+    /**
+     * Sets a Rational value for the specified tag.
+     * @param tagType the tag's value as an int
+     * @param rational rational number
+     */
+    public void setRational(int tagType, Rational rational)
+    {
+        setObject(tagType, rational);
+    }
+
+    /**
+     * Sets a Rational array for the specified tag.
+     * @param tagType the tag identifier
+     * @param rationals the Rational array to store
+     */
+    public void setRationalArray(int tagType, Rational[] rationals)
+    {
+        setObjectArray(tagType, rationals);
+    }
+
+    /**
+     * Sets an int array for the specified tag.
+     * @param tagType the tag identifier
+     * @param ints the int array to store
+     */
+    public void setIntArray(int tagType, int[] ints)
+    {
+        setObjectArray(tagType, ints);
+    }
+
+    /**
+     * Sets a byte array for the specified tag.
+     * @param tagType the tag identifier
+     * @param bytes the byte array to store
+     */
+    public void setByteArray(int tagType, byte[] bytes)
+    {
+        setObjectArray(tagType, bytes);
+    }
+
+    /**
+     * Sets a String array for the specified tag.
+     * @param tagType the tag identifier
+     * @param strings the String array to store
+     */
+    public void setStringArray(int tagType, String[] strings)
+    {
+        setObjectArray(tagType, strings);
+    }
+
+    /**
+     * Private helper method, containing common functionality for all 'add'
+     * methods.
+     * @param tagType the tag's value as an int
+     * @param value the value for the specified tag
+     * @throws NullPointerException if value is <code>null</code>
+     */
+    public void setObject(int tagType, Object value)
+    {
+        if (value==null) {
+            throw new NullPointerException("cannot set a null object");
+        }
+
+        Integer key = new Integer(tagType);
+        if (!_tagMap.containsKey(key)) {
+            _definedTagList.add(new Tag(tagType, this));
+        }
+        _tagMap.put(key, value);
+    }
+
+    /**
+     * Private helper method, containing common functionality for all 'add...Array'
+     * methods.
+     * @param tagType the tag's value as an int
+     * @param array the array of values for the specified tag
+     */
+    public void setObjectArray(int tagType, Object array)
+    {
+        // for now, we don't do anything special -- this method might be a candidate for removal once the dust settles
+        setObject(tagType, array);
+    }
+
+// TAG GETTERS
+
+    /**
+     * Returns the specified tag's value as an int, if possible.
+     */
+    public int getInt(int tagType) throws MetadataException
+    {
+        Object o = getObject(tagType);
+        if (o==null) {
+            throw new MetadataException("Tag " + getTagName(tagType) + " has not been set -- check using containsTag() first");
+        } else if (o instanceof String) {
+            try {
+                return Integer.parseInt((String)o);
+            } catch (NumberFormatException nfe) {
+                // convert the char array to an int
+                String s = (String)o;
+                byte[] bytes = s.getBytes();
+                long val = 0;
+                for (int i = 0; i < bytes.length; i++) {
+                    val = val << 8;
+                    val += bytes[i];
+                }
+                return (int)val;
+            }
+        } else if (o instanceof Number) {
+            return ((Number)o).intValue();
+        } else if (o instanceof Rational[]) {
+            Rational[] rationals = (Rational[])o;
+            if (rationals.length==1)
+                return rationals[0].intValue();
+        } else if (o instanceof byte[]) {
+            byte[] bytes = (byte[])o;
+            if (bytes.length==1)
+                return bytes[0];
+        } else if (o instanceof int[]) {
+            int[] ints = (int[])o;
+            if (ints.length==1)
+                return ints[0];
+        }
+        throw new MetadataException("Tag '" + tagType + "' cannot be cast to int.  It is of type '" + o.getClass() + "'.");
+    }
+
+    // TODO get Array methods need to return cloned data, to maintain this directory's integrity
+
+    /**
+     * Gets the specified tag's value as a String array, if possible.  Only supported
+     * where the tag is set as String[], String, int[], byte[] or Rational[].
+     * @param tagType the tag identifier
+     * @return the tag's value as an array of Strings
+     * @throws MetadataException if the tag has not been set or cannot be represented
+     *         as a String[]
+     */
+    public String[] getStringArray(int tagType) throws MetadataException
+    {
+        Object o = getObject(tagType);
+        if (o==null) {
+            throw new MetadataException("Tag " + getTagName(tagType) + " has not been set -- check using containsTag() first");
+        } else if (o instanceof String[]) {
+            return (String[])o;
+        } else if (o instanceof String) {
+            String[] strings = {(String)o};
+            return strings;
+        } else if (o instanceof int[]) {
+            int[] ints = (int[])o;
+            String[] strings = new String[ints.length];
+            for (int i = 0; i<strings.length; i++) {
+                strings[i] = Integer.toString(ints[i]);
+            }
+            return strings;
+        } else if (o instanceof byte[]) {
+            byte[] bytes = (byte[])o;
+            String[] strings = new String[bytes.length];
+            for (int i = 0; i<strings.length; i++) {
+                strings[i] = Byte.toString(bytes[i]);
+            }
+            return strings;
+        } else if (o instanceof Rational[]) {
+            Rational[] rationals = (Rational[])o;
+            String[] strings = new String[rationals.length];
+            for (int i = 0; i<strings.length; i++) {
+                strings[i] = rationals[i].toSimpleString(false);
+            }
+            return strings;
+        }
+        throw new MetadataException("Tag '" + tagType + "' cannot be cast to an String array.  It is of type '" + o.getClass() + "'.");
+    }
+
+    /**
+     * Gets the specified tag's value as an int array, if possible.  Only supported
+     * where the tag is set as String, int[], byte[] or Rational[].
+     * @param tagType the tag identifier
+     * @return the tag's value as an int array
+     * @throws MetadataException if the tag has not been set, or cannot be converted to
+     *         an int array
+     */
+    public int[] getIntArray(int tagType) throws MetadataException
+    {
+        Object o = getObject(tagType);
+        if (o==null) {
+            throw new MetadataException("Tag " + getTagName(tagType) + " has not been set -- check using containsTag() first");
+        } else if (o instanceof Rational[]) {
+            Rational[] rationals = (Rational[])o;
+            int[] ints = new int[rationals.length];
+            for (int i = 0; i<ints.length; i++) {
+                ints[i] = rationals[i].intValue();
+            }
+            return ints;
+        } else if (o instanceof int[]) {
+            return (int[])o;
+        } else if (o instanceof byte[]) {
+            byte[] bytes = (byte[])o;
+            int[] ints = new int[bytes.length];
+            for (int i = 0; i<bytes.length; i++) {
+                byte b = bytes[i];
+                ints[i] = b;
+            }
+            return ints;
+        } else if (o instanceof String) {
+            String str = (String)o;
+            int[] ints = new int[str.length()];
+            for (int i = 0; i<str.length(); i++) {
+                ints[i] = str.charAt(i);
+            }
+            return ints;
+        }
+        throw new MetadataException("Tag '" + tagType + "' cannot be cast to an int array.  It is of type '" + o.getClass() + "'.");
+    }
+
+    /**
+     * Gets the specified tag's value as an byte array, if possible.  Only supported
+     * where the tag is set as String, int[], byte[] or Rational[].
+     * @param tagType the tag identifier
+     * @return the tag's value as a byte array
+     * @throws MetadataException if the tag has not been set, or cannot be converted to
+     *         a byte array
+     */
+    public byte[] getByteArray(int tagType) throws MetadataException
+    {
+        Object o = getObject(tagType);
+        if (o==null) {
+            throw new MetadataException("Tag " + getTagName(tagType) + " has not been set -- check using containsTag() first");
+        } else if (o instanceof Rational[]) {
+            Rational[] rationals = (Rational[])o;
+            byte[] bytes = new byte[rationals.length];
+            for (int i = 0; i<bytes.length; i++) {
+                bytes[i] = rationals[i].byteValue();
+            }
+            return bytes;
+        } else if (o instanceof byte[]) {
+            return (byte[])o;
+        } else if (o instanceof int[]) {
+            int[] ints = (int[])o;
+            byte[] bytes = new byte[ints.length];
+            for (int i = 0; i<ints.length; i++) {
+                bytes[i] = (byte)ints[i];
+            }
+            return bytes;
+        } else if (o instanceof String) {
+            String str = (String)o;
+            byte[] bytes = new byte[str.length()];
+            for (int i = 0; i<str.length(); i++) {
+                bytes[i] = (byte)str.charAt(i);
+            }
+            return bytes;
+        }
+        throw new MetadataException("Tag '" + tagType + "' cannot be cast to a byte array.  It is of type '" + o.getClass() + "'.");
+    }
+
+    /**
+     * Returns the specified tag's value as a double, if possible.
+     */
+    public double getDouble(int tagType) throws MetadataException
+    {
+        Object o = getObject(tagType);
+        if (o==null) {
+            throw new MetadataException("Tag " + getTagName(tagType) + " has not been set -- check using containsTag() first");
+        } else if (o instanceof String) {
+            try {
+                return Double.parseDouble((String)o);
+            } catch (NumberFormatException nfe) {
+                throw new MetadataException("unable to parse string " + o + " as a double", nfe);
+            }
+        } else if (o instanceof Number) {
+            return ((Number)o).doubleValue();
+        }
+        throw new MetadataException("Tag '" + tagType + "' cannot be cast to a double.  It is of type '" + o.getClass() + "'.");
+    }
+
+    /**
+     * Returns the specified tag's value as a float, if possible.
+     */
+    public float getFloat(int tagType) throws MetadataException
+    {
+        Object o = getObject(tagType);
+        if (o==null) {
+            throw new MetadataException("Tag " + getTagName(tagType) + " has not been set -- check using containsTag() first");
+        } else if (o instanceof String) {
+            try {
+                return Float.parseFloat((String)o);
+            } catch (NumberFormatException nfe) {
+                throw new MetadataException("unable to parse string " + o + " as a float", nfe);
+            }
+        } else if (o instanceof Number) {
+            return ((Number)o).floatValue();
+        }
+        throw new MetadataException("Tag '" + tagType + "' cannot be cast to a float.  It is of type '" + o.getClass() + "'.");
+    }
+
+    /**
+     * Returns the specified tag's value as a long, if possible.
+     */
+    public long getLong(int tagType) throws MetadataException
+    {
+        Object o = getObject(tagType);
+        if (o==null) {
+            throw new MetadataException("Tag " + getTagName(tagType) + " has not been set -- check using containsTag() first");
+        } else if (o instanceof String) {
+            try {
+                return Long.parseLong((String)o);
+            } catch (NumberFormatException nfe) {
+                throw new MetadataException("unable to parse string " + o + " as a long", nfe);
+            }
+        } else if (o instanceof Number) {
+            return ((Number)o).longValue();
+        }
+        throw new MetadataException("Tag '" + tagType + "' cannot be cast to a long.  It is of type '" + o.getClass() + "'.");
+    }
+
+    /**
+     * Returns the specified tag's value as a boolean, if possible.
+     */
+    public boolean getBoolean(int tagType) throws MetadataException
+    {
+        Object o = getObject(tagType);
+        if (o==null) {
+            throw new MetadataException("Tag " + getTagName(tagType) + " has not been set -- check using containsTag() first");
+        } else if (o instanceof Boolean) {
+            return ((Boolean)o).booleanValue();
+        } else if (o instanceof String) {
+            try {
+                return Boolean.getBoolean((String)o);
+            } catch (NumberFormatException nfe) {
+                throw new MetadataException("unable to parse string " + o + " as a boolean", nfe);
+            }
+        } else if (o instanceof Number) {
+            return (((Number)o).doubleValue()!=0);
+        }
+        throw new MetadataException("Tag '" + tagType + "' cannot be cast to a boolean.  It is of type '" + o.getClass() + "'.");
+    }
+
+    /**
+     * Returns the specified tag's value as a java.util.Date, if possible.
+     */
+    public java.util.Date getDate(int tagType) throws MetadataException
+    {
+        Object o = getObject(tagType);
+        if (o==null) {
+            throw new MetadataException("Tag " + getTagName(tagType) + " has not been set -- check using containsTag() first");
+        } else if (o instanceof java.util.Date) {
+            return (java.util.Date)o;
+        } else if (o instanceof String) {
+            // add new dateformat strings to make this method even smarter
+            // so far, this seems to cover all known date strings
+            // (for example, AM and PM strings are not supported...)
+            String datePatterns[] = {
+                "yyyy:MM:dd HH:mm:ss",
+                "yyyy:MM:dd HH:mm",
+                "yyyy-MM-dd HH:mm:ss",
+                "yyyy-MM-dd HH:mm"};
+            String dateString = (String)o;
+            for (int i = 0; i<datePatterns.length; i++) {
+                try {
+                    DateFormat parser = new java.text.SimpleDateFormat(datePatterns[i]);
+                    return parser.parse(dateString);
+                } catch (java.text.ParseException ex) {
+                    // simply try the next pattern
+                }
+            }
+        }
+        throw new MetadataException("Tag '" + tagType + "' cannot be cast to a java.util.Date.  It is of type '" + o.getClass() + "'.");
+    }
+
+    /**
+     * Returns the specified tag's value as a Rational, if possible.
+     */
+    public Rational getRational(int tagType) throws MetadataException
+    {
+        Object o = getObject(tagType);
+        if (o==null) {
+            throw new MetadataException("Tag " + getTagName(tagType) + " has not been set -- check using containsTag() first");
+        } else if (o instanceof Rational) {
+            return (Rational)o;
+        }
+        throw new MetadataException("Tag '" + tagType + "' cannot be cast to a Rational.  It is of type '" + o.getClass() + "'.");
+    }
+
+    public Rational[] getRationalArray(int tagType) throws MetadataException
+    {
+        Object o = getObject(tagType);
+        if (o==null) {
+            throw new MetadataException("Tag " + getTagName(tagType) + " has not been set -- check using containsTag() first");
+        } else if (o instanceof Rational[]) {
+            return (Rational[])o;
+        }
+        throw new MetadataException("Tag '" + tagType + "' cannot be cast to a Rational array.  It is of type '" + o.getClass() + "'.");
+    }
+
+    /**
+     * Returns the specified tag's value as a String.  This value is the 'raw' value.  A more presentable decoding
+     * of this value may be obtained from the corresponding Descriptor.
+     * @return the String reprensentation of the tag's value, or
+     *         <code>null</code> if the tag hasn't been defined.
+     */
+    public String getString(int tagType)
+    {
+        Object o = getObject(tagType);
+        if (o==null)
+            return null;
+
+        if (o instanceof Rational)
+            return ((Rational)o).toSimpleString(true);
+
+        if (o.getClass().isArray())
+        {
+            // handle arrays of objects and primitives
+            int arrayLength = Array.getLength(o);
+            // determine if this is an array of objects i.e. [Lcom.drew.blah
+            boolean isObjectArray = o.getClass().toString().startsWith("class [L");
+            StringBuffer sbuffer = new StringBuffer();
+            for (int i = 0; i<arrayLength; i++)
+            {
+                if (i!=0)
+                    sbuffer.append(' ');
+                if (isObjectArray)
+                    sbuffer.append(Array.get(o, i).toString());
+                else
+                    sbuffer.append(Array.getInt(o, i));
+            }
+            return sbuffer.toString();
+        }
+
+        return o.toString();
+    }
+
+    /**
+     * Returns the object hashed for the particular tag type specified, if available.
+     * @param tagType the tag type identifier
+     * @return the tag's value as an Object if available, else null
+     */
+    public Object getObject(int tagType)
+    {
+        return _tagMap.get(new Integer(tagType));
+    }
+
+// OTHER METHODS
+
+    /**
+     * Returns the name of a specified tag as a String.
+     * @param tagType the tag type identifier
+     * @return the tag's name as a String
+     */
+    public String getTagName(int tagType)
+    {
+        Integer key = new Integer(tagType);
+        HashMap nameMap = getTagNameMap();
+        if (!nameMap.containsKey(key)) {
+            String hex = Integer.toHexString(tagType);
+            while (hex.length()<4) {
+                hex = "0" + hex;
+            }
+            return "Unknown tag (0x" + hex + ")";
+        }
+        return (String)nameMap.get(key);
+    }
+
+    /**
+     * Provides a description of a tag's value using the descriptor set by
+     * <code>setDescriptor(Descriptor)</code>.
+     * @param tagType the tag type identifier
+     * @return the tag value's description as a String
+     * @throws MetadataException if a descriptor hasn't been set, or if an error
+     * occurs during calculation of the description within the Descriptor
+     */
+    public String getDescription(int tagType) throws MetadataException
+    {
+        if (_descriptor==null) {
+            throw new MetadataException("a descriptor must be set using setDescriptor(...) before descriptions can be provided");
+        }
+
+        return _descriptor.getDescription(tagType);
+    }
+}
Index: /trunk/src/com/drew/metadata/Metadata.java
===================================================================
--- /trunk/src/com/drew/metadata/Metadata.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/Metadata.java	(revision 4231)
@@ -0,0 +1,124 @@
+/*
+ * Metadata.java
+ *
+ * This class is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  Similarly, I release this Java version under the
+ * same license, though I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew.noakes@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created on 28 April 2002, 17:40
+ * Modified 04 Aug 2002
+ * - Adjusted javadoc
+ * - Added
+ * Modified 29 Oct 2002 (v1.2)
+ * - Stored IFD directories in separate tag-spaces
+ * - iterator() now returns an Iterator over a list of TagValue objects
+ * - More get*Description() methods to detail GPS tags, among others
+ * - Put spaces between words of tag name for presentation reasons (they had no
+ *   significance in compound form)
+ */
+package com.drew.metadata;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+
+/**
+ * Result from an exif extraction operation, containing all tags, their
+ * values and support for retrieving them.
+ * @author  Drew Noakes http://drewnoakes.com
+ */
+public final class Metadata implements Serializable
+{
+    /**
+     *
+     */
+    private final HashMap directoryMap;
+
+    /**
+     * List of Directory objects set against this object.  Keeping a list handy makes
+     * creation of an Iterator and counting tags simple.
+     */
+    private final ArrayList directoryList;
+
+    /**
+     * Creates a new instance of Metadata.  Package private.
+     */
+    public Metadata()
+    {
+        directoryMap = new HashMap();
+        directoryList = new ArrayList();
+    }
+
+
+// OTHER METHODS
+
+    /**
+     * Creates an Iterator over the tag types set against this image, preserving the order
+     * in which they were set.  Should the same tag have been set more than once, it's first
+     * position is maintained, even though the final value is used.
+     * @return an Iterator of tag types set for this image
+     */
+    public Iterator getDirectoryIterator()
+    {
+        return directoryList.iterator();
+    }
+
+    /**
+     * Returns a count of unique directories in this metadata collection.
+     * @return the number of unique directory types set for this metadata collection
+     */
+    public int getDirectoryCount()
+    {
+        return directoryList.size();
+    }
+
+    /**
+     * Returns a <code>Directory</code> of specified type.  If this <code>Metadata</code> object already contains
+     * such a directory, it is returned.  Otherwise a new instance of this directory will be created and stored within
+     * this Metadata object.
+     * @param type the type of the Directory implementation required.
+     * @return a directory of the specified type.
+     */
+    public Directory getDirectory(Class type)
+    {
+        if (!Directory.class.isAssignableFrom(type)) {
+            throw new RuntimeException("Class type passed to getDirectory must be an implementation of com.drew.metadata.Directory");
+        }
+        // check if we've already issued this type of directory
+        if (directoryMap.containsKey(type)) {
+            return (Directory)directoryMap.get(type);
+        }
+        Object directory;
+        try {
+            directory = type.newInstance();
+        } catch (Exception e) {
+            throw new RuntimeException("Cannot instantiate provided Directory type: " + type.toString());
+        }
+        // store the directory in case it's requested later
+        directoryMap.put(type, directory);
+        directoryList.add(directory);
+        return (Directory)directory;
+    }
+
+    /**
+     * Indicates whether a given directory type has been created in this metadata
+     * repository.  Directories are created by calling getDirectory(Class).
+     * @param type the Directory type
+     * @return true if the metadata directory has been created
+     */
+    public boolean containsDirectory(Class type)
+    {
+        return directoryMap.containsKey(type);
+    }
+}
Index: /trunk/src/com/drew/metadata/MetadataException.java
===================================================================
--- /trunk/src/com/drew/metadata/MetadataException.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/MetadataException.java	(revision 4231)
@@ -0,0 +1,40 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 13-Nov-2002 18:10:23 using IntelliJ IDEA.
+ */
+package com.drew.metadata;
+
+import com.drew.lang.CompoundException;
+
+/**
+ *
+ */
+public class MetadataException extends CompoundException
+{
+    public MetadataException(String msg)
+    {
+        super(msg);
+    }
+
+    public MetadataException(Throwable exception)
+    {
+        super(exception);
+    }
+
+    public MetadataException(String msg, Throwable innerException)
+    {
+        super(msg, innerException);
+    }
+}
Index: /trunk/src/com/drew/metadata/MetadataReader.java
===================================================================
--- /trunk/src/com/drew/metadata/MetadataReader.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/MetadataReader.java	(revision 4231)
@@ -0,0 +1,27 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 26-Nov-2002 11:21:43 using IntelliJ IDEA.
+ */
+package com.drew.metadata;
+
+/**
+ *
+ */
+public interface MetadataReader
+{
+    public Metadata extract();
+
+    public Metadata extract(Metadata metadata);
+}
Index: /trunk/src/com/drew/metadata/SampleUsage.java
===================================================================
--- /trunk/src/com/drew/metadata/SampleUsage.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/SampleUsage.java	(revision 4231)
@@ -0,0 +1,120 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 05-Nov-2002 18:57:14 using IntelliJ IDEA.
+ */
+package com.drew.metadata;
+
+import com.drew.imaging.jpeg.JpegMetadataReader;
+import com.drew.imaging.jpeg.JpegProcessingException;
+import com.drew.imaging.jpeg.JpegSegmentReader;
+import com.drew.metadata.exif.ExifReader;
+import com.drew.metadata.iptc.IptcReader;
+
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.Iterator;
+
+/**
+ *
+ */
+public class SampleUsage
+{
+    /**
+     * Constructor which executes multiple sample usages, each of which return the same output.  This class showcases
+     * multiple usages of this metadata class library.
+     * @param fileName path to a jpeg file upon which to operate
+     */
+    public SampleUsage(String fileName)
+    {
+        File jpegFile = new File(fileName);
+
+        // There are multiple ways to get a Metadata object
+
+        // Approach 1
+        // This approach reads all types of known Jpeg metadata (at present,
+        // Exif and Iptc) in a single call.  In most cases, this is the most
+        // appropriate usage.
+        try {
+            Metadata metadata = JpegMetadataReader.readMetadata(jpegFile);
+            printImageTags(1, metadata);
+        } catch (JpegProcessingException e) {
+            System.err.println("error 1a");
+        }
+
+        // Approach 2
+        // This approach shows using individual MetadataReader implementations
+        // to read a file.  This is less efficient than approach 1, as the file
+        // is opened and read twice.
+        try {
+            Metadata metadata = new Metadata();
+            new ExifReader(jpegFile).extract(metadata);
+            new IptcReader(jpegFile).extract(metadata);
+            printImageTags(2, metadata);
+        } catch (JpegProcessingException jpe) {
+            System.err.println("error 2a");
+        }
+
+        // Approach 3
+        // As fast as approach 1 (this is what goes on inside the JpegMetadataReader's
+        // readMetadata() method), this code is handy if you want to look into other
+        // Jpeg segments too.
+        try {
+            JpegSegmentReader segmentReader = new JpegSegmentReader(jpegFile);
+            byte[] exifSegment = segmentReader.readSegment(JpegSegmentReader.SEGMENT_APP1);
+            byte[] iptcSegment = segmentReader.readSegment(JpegSegmentReader.SEGMENT_APPD);
+            Metadata metadata = new Metadata();
+            new ExifReader(exifSegment).extract(metadata);
+            new IptcReader(iptcSegment).extract(metadata);
+            printImageTags(3, metadata);
+        } catch (JpegProcessingException jpe) {
+            System.err.println("error 3a");
+        }
+    }
+
+    private void printImageTags(int approachCount, Metadata metadata)
+    {
+        System.out.println();
+        System.out.println("*** APPROACH " + approachCount + " ***");
+        System.out.println();
+        // iterate over the exif data and print to System.out
+        Iterator directories = metadata.getDirectoryIterator();
+        while (directories.hasNext()) {
+            Directory directory = (Directory)directories.next();
+            Iterator tags = directory.getTagIterator();
+            while (tags.hasNext()) {
+                Tag tag = (Tag)tags.next();
+                System.out.println(tag);
+            }
+            if (directory.hasErrors()) {
+                Iterator errors = directory.getErrors();
+                while (errors.hasNext()) {
+                    System.out.println("ERROR: " + errors.next());
+                }
+            }
+        }
+    }
+
+    /**
+     * Executes the sample usage program.
+     * @param args command line parameters
+     */
+    public static void main(String[] args)
+    {
+        new SampleUsage("src/com/drew/metadata/test/withIptcExifGps.jpg");
+    }
+}
Index: /trunk/src/com/drew/metadata/Tag.java
===================================================================
--- /trunk/src/com/drew/metadata/Tag.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/Tag.java	(revision 4231)
@@ -0,0 +1,101 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 26-Nov-2002 18:29:12 using IntelliJ IDEA.
+ */
+package com.drew.metadata;
+
+import java.io.Serializable;
+
+/**
+ *
+ */
+public class Tag implements Serializable
+{
+    private final int _tagType;
+    private final Directory _directory;
+
+    public Tag(int tagType, Directory directory)
+    {
+        _tagType = tagType;
+        _directory = directory;
+    }
+
+    /**
+     * Gets the tag type as an int
+     * @return the tag type as an int
+     */
+    public int getTagType()
+    {
+        return _tagType;
+    }
+
+    /**
+     * Gets the tag type in hex notation as a String with padded leading
+     * zeroes if necessary (i.e. <code>0x100E</code>).
+     * @return the tag type as a string in hexadecimal notation
+     */
+    public String getTagTypeHex()
+    {
+        String hex = Integer.toHexString(_tagType);
+        while (hex.length() < 4) hex = "0" + hex;
+        return "0x" + hex;
+    }
+
+    /**
+     * Get a description of the tag's value, considering enumerated values
+     * and units.
+     * @return a description of the tag's value
+     */
+    public String getDescription() throws MetadataException
+    {
+        return _directory.getDescription(_tagType);
+    }
+
+    /**
+     * Get the name of the tag, such as <code>Aperture</code>, or
+     * <code>InteropVersion</code>.
+     * @return the tag's name
+     */
+    public String getTagName()
+    {
+        return _directory.getTagName(_tagType);
+    }
+
+    /**
+     * Get the name of the directory in which the tag exists, such as
+     * <code>Exif</code>, <code>GPS</code> or <code>Interoperability</code>.
+     * @return name of the directory in which this tag exists
+     */
+    public String getDirectoryName()
+    {
+        return _directory.getName();
+    }
+
+    /**
+     * A basic representation of the tag's type and value in format:
+     * <code>FNumber - F2.8</code>.
+     * @return the tag's type and value
+     */
+    public String toString()
+    {
+        String description;
+        try {
+            description = getDescription();
+        } catch (MetadataException e) {
+            description = _directory.getString(getTagType()) + " (unable to formulate description)";
+        }
+        return "[" + _directory.getName() + "] " + getTagName() + " - " + description;
+    }
+}
Index: /trunk/src/com/drew/metadata/TagDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/TagDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/TagDescriptor.java	(revision 4231)
@@ -0,0 +1,46 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata;
+
+import java.io.Serializable;
+
+/**
+ * Abstract base class for all tag descriptor classes.  Implementations are responsible for
+ * providing the human-readable string represenation of tag values stored in a directory.
+ * The directory is provided to the tag descriptor via its constructor.
+ */
+public abstract class TagDescriptor implements Serializable
+{
+    protected final Directory _directory;
+
+    public TagDescriptor(Directory directory)
+    {
+        _directory = directory;
+    }
+
+    /**
+     * Returns a descriptive value of the the specified tag for this image.
+     * Where possible, known values will be substituted here in place of the raw
+     * tokens actually kept in the Exif segment.  If no substitution is
+     * available, the value provided by getString(int) will be returned.
+     * <p>
+     * This and getString(int) are the only 'get' methods that won't throw an
+     * exception.
+     * @param tagType the tag to find a description for
+     * @return a description of the image's value for the specified tag, or
+     *         <code>null</code> if the tag hasn't been defined.
+     */
+    public abstract String getDescription(int tagType) throws MetadataException;
+}
Index: /trunk/src/com/drew/metadata/exif/CanonMakernoteDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/CanonMakernoteDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/CanonMakernoteDescriptor.java	(revision 4231)
@@ -0,0 +1,716 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:12:05 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ *
+ */
+public class CanonMakernoteDescriptor extends TagDescriptor
+{
+    public CanonMakernoteDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType) {
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_FLASH_ACTIVITY:
+                return getFlashActivityDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_FOCUS_TYPE:
+                return getFocusTypeDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_DIGITAL_ZOOM:
+                return getDigitalZoomDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_QUALITY:
+                return getQualityDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_MACRO_MODE:
+                return getMacroModeDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_SELF_TIMER_DELAY:
+                return getSelfTimerDelayDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_FLASH_MODE:
+                return getFlashModeDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_CONTINUOUS_DRIVE_MODE:
+                return getContinuousDriveModeDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_FOCUS_MODE_1:
+                return getFocusMode1Description();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_IMAGE_SIZE:
+                return getImageSizeDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_EASY_SHOOTING_MODE:
+                return getEasyShootingModeDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_CONTRAST:
+                return getContrastDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_SATURATION:
+                return getSaturationDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_SHARPNESS:
+                return getSharpnessDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_ISO:
+                return getIsoDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_METERING_MODE:
+                return getMeteringModeDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_AF_POINT_SELECTED:
+                return getAfPointSelectedDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_EXPOSURE_MODE:
+                return getExposureModeDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_LONG_FOCAL_LENGTH:
+                return getLongFocalLengthDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_SHORT_FOCAL_LENGTH:
+                return getShortFocalLengthDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_FOCAL_UNITS_PER_MM:
+                return getFocalUnitsPerMillimetreDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_FLASH_DETAILS:
+                return getFlashDetailsDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE1_FOCUS_MODE_2:
+                return getFocusMode2Description();
+            case CanonMakernoteDirectory.TAG_CANON_STATE2_WHITE_BALANCE:
+                return getWhiteBalanceDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE2_AF_POINT_USED:
+                return getAfPointUsedDescription();
+            case CanonMakernoteDirectory.TAG_CANON_STATE2_FLASH_BIAS:
+                return getFlashBiasDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_LONG_EXPOSURE_NOISE_REDUCTION:
+                return getLongExposureNoiseReductionDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SHUTTER_AUTO_EXPOSURE_LOCK_BUTTONS:
+                return getShutterAutoExposureLockButtonDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_MIRROR_LOCKUP:
+                return getMirrorLockupDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_TV_AV_AND_EXPOSURE_LEVEL:
+                return getTvAndAvExposureLevelDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_AF_ASSIST_LIGHT:
+                return getAutoFocusAssistLightDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SHUTTER_SPEED_IN_AV_MODE:
+                return getShutterSpeedInAvModeDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_BRACKETTING:
+                return getAutoExposureBrackettingSequenceAndAutoCancellationDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SHUTTER_CURTAIN_SYNC:
+                return getShutterCurtainSyncDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_AF_STOP:
+                return getLensAutoFocusStopButtonDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_FILL_FLASH_REDUCTION:
+                return getFillFlashReductionDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_MENU_BUTTON_RETURN:
+                return getMenuButtonReturnPositionDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SET_BUTTON_FUNCTION:
+                return getSetButtonFunctionWhenShootingDescription();
+            case CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SENSOR_CLEANING:
+                return getSensorCleaningDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getLongExposureNoiseReductionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_LONG_EXPOSURE_NOISE_REDUCTION)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_LONG_EXPOSURE_NOISE_REDUCTION);
+        switch (value) {
+            case 0:     return "Off";
+            case 1:     return "On";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+    public String getShutterAutoExposureLockButtonDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SHUTTER_AUTO_EXPOSURE_LOCK_BUTTONS)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SHUTTER_AUTO_EXPOSURE_LOCK_BUTTONS);
+        switch (value) {
+            case 0:     return "AF/AE lock";
+            case 1:     return "AE lock/AF";
+            case 2:     return "AF/AF lock";
+            case 3:     return "AE+release/AE+AF";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+    public String getMirrorLockupDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_MIRROR_LOCKUP)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_MIRROR_LOCKUP);
+        switch (value) {
+            case 0:     return "Disabled";
+            case 1:     return "Enabled";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+    public String getTvAndAvExposureLevelDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_TV_AV_AND_EXPOSURE_LEVEL)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_TV_AV_AND_EXPOSURE_LEVEL);
+        switch (value) {
+            case 0:     return "1/2 stop";
+            case 1:     return "1/3 stop";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+    public String getAutoFocusAssistLightDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_AF_ASSIST_LIGHT)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_AF_ASSIST_LIGHT);
+        switch (value) {
+            case 0:     return "On (Auto)";
+            case 1:     return "Off";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+    public String getShutterSpeedInAvModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SHUTTER_SPEED_IN_AV_MODE)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SHUTTER_SPEED_IN_AV_MODE);
+        switch (value) {
+            case 0:     return "Automatic";
+            case 1:     return "1/200 (fixed)";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+    public String getAutoExposureBrackettingSequenceAndAutoCancellationDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_BRACKETTING)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_BRACKETTING);
+        switch (value) {
+            case 0:     return "0,-,+ / Enabled";
+            case 1:     return "0,-,+ / Disabled";
+            case 2:     return "-,0,+ / Enabled";
+            case 3:     return "-,0,+ / Disabled";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+    public String getShutterCurtainSyncDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SHUTTER_CURTAIN_SYNC)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SHUTTER_CURTAIN_SYNC);
+        switch (value) {
+            case 0:     return "1st Curtain Sync";
+            case 1:     return "2nd Curtain Sync";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+    public String getLensAutoFocusStopButtonDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_AF_STOP)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_AF_STOP);
+        switch (value) {
+            case 0:     return "AF stop";
+            case 1:     return "Operate AF";
+            case 2:     return "Lock AE and start timer";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+    public String getFillFlashReductionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_FILL_FLASH_REDUCTION)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_FILL_FLASH_REDUCTION);
+        switch (value) {
+            case 0:     return "Enabled";
+            case 1:     return "Disabled";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+    public String getMenuButtonReturnPositionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_MENU_BUTTON_RETURN)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_MENU_BUTTON_RETURN);
+        switch (value) {
+            case 0:     return "Top";
+            case 1:     return "Previous (volatile)";
+            case 2:     return "Previous";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+    public String getSetButtonFunctionWhenShootingDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SET_BUTTON_FUNCTION)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SET_BUTTON_FUNCTION);
+        switch (value) {
+            case 0:     return "Not Assigned";
+            case 1:     return "Change Quality";
+            case 2:     return "Change ISO Speed";
+            case 3:     return "Select Parameters";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+    public String getSensorCleaningDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SENSOR_CLEANING)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_CUSTOM_FUNCTION_SENSOR_CLEANING);
+        switch (value) {
+            case 0:     return "Disabled";
+            case 1:     return "Enabled";
+            default:    return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFlashBiasDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE2_FLASH_BIAS)) return null;
+
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE2_FLASH_BIAS);
+
+        boolean isNegative = false;
+        if (value > 0xF000)
+        {
+            isNegative = true;
+            value = 0xFFFF - value;
+            value++;
+        }
+
+        // this tag is interesting in that the values returned are:
+        //  0, 0.375, 0.5, 0.626, 1
+        // not
+        //  0, 0.33,  0.5, 0.66,  1
+
+        return ((isNegative) ? "-" : "") + Float.toString(value / 32f) + " EV";
+    }
+
+    public String getAfPointUsedDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE2_AF_POINT_USED)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE2_AF_POINT_USED);
+        if ((value & 0x7) == 0) {
+            return "Right";
+        } else if ((value & 0x7) == 1) {
+            return "Centre";
+        } else if ((value & 0x7) == 2) {
+            return "Left";
+        } else {
+            return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getWhiteBalanceDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE2_WHITE_BALANCE)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE2_WHITE_BALANCE);
+        switch (value) {
+            case 0:
+                return "Auto";
+            case 1:
+                return "Sunny";
+            case 2:
+                return "Cloudy";
+            case 3:
+                return "Tungsten";
+            case 4:
+                return "Flourescent";
+            case 5:
+                return "Flash";
+            case 6:
+                return "Custom";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFocusMode2Description() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_FOCUS_MODE_2)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_FOCUS_MODE_2);
+        switch (value) {
+            case 0:
+                return "Single";
+            case 1:
+                return "Continuous";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFlashDetailsDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_FLASH_DETAILS)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_FLASH_DETAILS);
+        if (((value << 14) & 1) > 0) {
+            return "External E-TTL";
+        }
+        if (((value << 13) & 1) > 0) {
+            return "Internal flash";
+        }
+        if (((value << 11) & 1) > 0) {
+            return "FP sync used";
+        }
+        if (((value << 4) & 1) > 0) {
+            return "FP sync enabled";
+        }
+        return "Unknown (" + value + ")";
+    }
+
+    public String getFocalUnitsPerMillimetreDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_FOCAL_UNITS_PER_MM)) return "";
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_FOCAL_UNITS_PER_MM);
+        if (value != 0) {
+            return Integer.toString(value);
+        } else {
+            return "";
+        }
+    }
+
+    public String getShortFocalLengthDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_SHORT_FOCAL_LENGTH)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_SHORT_FOCAL_LENGTH);
+        String units = getFocalUnitsPerMillimetreDescription();
+        return Integer.toString(value) + " " + units;
+    }
+
+    public String getLongFocalLengthDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_LONG_FOCAL_LENGTH)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_LONG_FOCAL_LENGTH);
+        String units = getFocalUnitsPerMillimetreDescription();
+        return Integer.toString(value) + " " + units;
+    }
+
+    public String getExposureModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_EXPOSURE_MODE)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_EXPOSURE_MODE);
+        switch (value) {
+            case 0:
+                return "Easy shooting";
+            case 1:
+                return "Program";
+            case 2:
+                return "Tv-priority";
+            case 3:
+                return "Av-priority";
+            case 4:
+                return "Manual";
+            case 5:
+                return "A-DEP";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getAfPointSelectedDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_AF_POINT_SELECTED)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_AF_POINT_SELECTED);
+        switch (value) {
+            case 0x3000:
+                return "None (MF)";
+            case 0x3001:
+                return "Auto selected";
+            case 0x3002:
+                return "Right";
+            case 0x3003:
+                return "Centre";
+            case 0x3004:
+                return "Left";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getMeteringModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_METERING_MODE)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_METERING_MODE);
+        switch (value) {
+            case 3:
+                return "Evaluative";
+            case 4:
+                return "Partial";
+            case 5:
+                return "Centre weighted";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getIsoDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_ISO)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_ISO);
+        switch (value) {
+            case 0:
+                return "Not specified (see ISOSpeedRatings tag)";
+            case 15:
+                return "Auto";
+            case 16:
+                return "50";
+            case 17:
+                return "100";
+            case 18:
+                return "200";
+            case 19:
+                return "400";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getSharpnessDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_SHARPNESS)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_SHARPNESS);
+        switch (value) {
+            case 0xFFFF:
+                return "Low";
+            case 0x000:
+                return "Normal";
+            case 0x001:
+                return "High";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getSaturationDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_SATURATION)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_SATURATION);
+        switch (value) {
+            case 0xFFFF:
+                return "Low";
+            case 0x000:
+                return "Normal";
+            case 0x001:
+                return "High";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getContrastDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_CONTRAST)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_CONTRAST);
+        switch (value) {
+            case 0xFFFF:
+                return "Low";
+            case 0x000:
+                return "Normal";
+            case 0x001:
+                return "High";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getEasyShootingModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_EASY_SHOOTING_MODE)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_EASY_SHOOTING_MODE);
+        switch (value) {
+            case 0:
+                return "Full auto";
+            case 1:
+                return "Manual";
+            case 2:
+                return "Landscape";
+            case 3:
+                return "Fast shutter";
+            case 4:
+                return "Slow shutter";
+            case 5:
+                return "Night";
+            case 6:
+                return "B&W";
+            case 7:
+                return "Sepia";
+            case 8:
+                return "Portrait";
+            case 9:
+                return "Sports";
+            case 10:
+                return "Macro / Closeup";
+            case 11:
+                return "Pan focus";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getImageSizeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_IMAGE_SIZE)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_IMAGE_SIZE);
+        switch (value) {
+            case 0:
+                return "Large";
+            case 1:
+                return "Medium";
+            case 2:
+                return "Small";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFocusMode1Description() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_FOCUS_MODE_1)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_FOCUS_MODE_1);
+        switch (value) {
+            case 0:
+                return "One-shot";
+            case 1:
+                return "AI Servo";
+            case 2:
+                return "AI Focus";
+            case 3:
+                return "Manual Focus";
+            case 4:
+                // TODO should check field 32 here (FOCUS_MODE_2)
+                return "Single";
+            case 5:
+                return "Continuous";
+            case 6:
+                return "Manual Focus";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getContinuousDriveModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_CONTINUOUS_DRIVE_MODE)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_CONTINUOUS_DRIVE_MODE);
+        switch (value) {
+            case 0:
+                if (_directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_SELF_TIMER_DELAY) == 0) {
+                    return "Single shot";
+                } else {
+                    return "Single shot with self-timer";
+                }
+            case 1:
+                return "Continuous";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFlashModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_FLASH_MODE)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_FLASH_MODE);
+        switch (value) {
+            case 0:
+                return "No flash fired";
+            case 1:
+                return "Auto";
+            case 2:
+                return "On";
+            case 3:
+                return "Red-eye reduction";
+            case 4:
+                return "Slow-synchro";
+            case 5:
+                return "Auto and red-eye reduction";
+            case 6:
+                return "On and red-eye reduction";
+            case 16:
+                // note: this value not set on Canon D30
+                return "Extenal flash";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getSelfTimerDelayDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_SELF_TIMER_DELAY)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_SELF_TIMER_DELAY);
+        if (value == 0) {
+            return "Self timer not used";
+        } else {
+            // TODO find an image that tests this calculation
+            return Double.toString((double)value * 0.1d) + " sec";
+        }
+    }
+
+    public String getMacroModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_MACRO_MODE)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_MACRO_MODE);
+        switch (value) {
+            case 1:
+                return "Macro";
+            case 2:
+                return "Normal";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getQualityDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_QUALITY)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_QUALITY);
+        switch (value) {
+            case 2:
+                return "Normal";
+            case 3:
+                return "Fine";
+            case 5:
+                return "Superfine";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getDigitalZoomDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_DIGITAL_ZOOM)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_DIGITAL_ZOOM);
+        switch (value) {
+            case 0:
+                return "No digital zoom";
+            case 1:
+                return "2x";
+            case 2:
+                return "4x";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFocusTypeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_FOCUS_TYPE)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_FOCUS_TYPE);
+        switch (value) {
+            case 0:
+                return "Manual";
+            case 1:
+                return "Auto";
+            case 3:
+                return "Close-up (Macro)";
+            case 8:
+                return "Locked (Pan Mode)";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFlashActivityDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CanonMakernoteDirectory.TAG_CANON_STATE1_FLASH_ACTIVITY)) return null;
+        int value = _directory.getInt(CanonMakernoteDirectory.TAG_CANON_STATE1_FLASH_ACTIVITY);
+        switch (value) {
+            case 0:
+                return "Flash did not fire";
+            case 1:
+                return "Flash fired";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/CanonMakernoteDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/CanonMakernoteDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/CanonMakernoteDirectory.java	(revision 4231)
@@ -0,0 +1,448 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:10:47 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ * Describes tags specific to Canon cameras.
+ *
+ * Thanks to Bill Richards for his contribution to this makernote directory.
+ *
+ * Many tag definitions explained here: http://www.ozhiker.com/electronics/pjmt/jpeg_info/canon_mn.html
+ */
+public class CanonMakernoteDirectory extends Directory
+{
+    // CANON cameras have some funny bespoke fields that need further processing...
+    public static final int TAG_CANON_CAMERA_STATE_1 = 0x0001;
+    public static final int TAG_CANON_CAMERA_STATE_2 = 0x0004;
+
+    public static final int TAG_CANON_IMAGE_TYPE = 0x0006;
+    public static final int TAG_CANON_FIRMWARE_VERSION = 0x0007;
+    public static final int TAG_CANON_IMAGE_NUMBER = 0x0008;
+    public static final int TAG_CANON_OWNER_NAME = 0x0009;
+    /**
+     * To display serial number as on camera use: printf( "%04X%05d", highbyte, lowbyte )
+     * TODO handle this in CanonMakernoteDescriptor
+     */
+    public static final int TAG_CANON_SERIAL_NUMBER = 0x000C;
+    public static final int TAG_CANON_UNKNOWN_1 = 0x000D;
+    public static final int TAG_CANON_CUSTOM_FUNCTIONS = 0x000F;
+
+    // These 'sub'-tag values have been created for consistency -- they don't exist within the exif segment
+    /**
+     * 1 = Macro
+     * 2 = Normal
+     */
+    public static final int TAG_CANON_STATE1_MACRO_MODE = 0xC101;
+    public static final int TAG_CANON_STATE1_SELF_TIMER_DELAY = 0xC102;
+    /**
+     * 2 = Normal
+     * 3 = Fine
+     * 5 = Superfine
+     */
+    public static final int TAG_CANON_STATE1_QUALITY = 0xC103;
+    /**
+     * 0 = Flash Not Fired
+     * 1 = Auto
+     * 2 = On
+     * 3 = Red Eye Reduction
+     * 4 = Slow Synchro
+     * 5 = Auto + Red Eye Reduction
+     * 6 = On + Red Eye Reduction
+     * 16 = External Flash
+     */
+    public static final int TAG_CANON_STATE1_FLASH_MODE = 0xC104;
+    /**
+     * 0 = Single Frame or Timer Mode
+     * 1 = Continuous
+     */
+    public static final int TAG_CANON_STATE1_CONTINUOUS_DRIVE_MODE = 0xC105;
+    public static final int TAG_CANON_STATE1_UNKNOWN_2 = 0xC106;
+    /**
+     * 0 = One-Shot
+     * 1 = AI Servo
+     * 2 = AI Focus
+     * 3 = Manual Focus
+     * 4 = Single
+     * 5 = Continuous
+     * 6 = Manual Focus
+     */
+    public static final int TAG_CANON_STATE1_FOCUS_MODE_1 = 0xC107;
+    public static final int TAG_CANON_STATE1_UNKNOWN_3 = 0xC108;
+    public static final int TAG_CANON_STATE1_UNKNOWN_4 = 0xC109;
+    /**
+     * 0 = Large
+     * 1 = Medium
+     * 2 = Small
+     */
+    public static final int TAG_CANON_STATE1_IMAGE_SIZE = 0xC10A;
+    /**
+     * 0 = Full Auto
+     * 1 = Manual
+     * 2 = Landscape
+     * 3 = Fast Shutter
+     * 4 = Slow Shutter
+     * 5 = Night
+     * 6 = Black & White
+     * 7 = Sepia
+     * 8 = Portrait
+     * 9 = Sports
+     * 10 = Macro / Close-Up
+     * 11 = Pan Focus
+     */
+    public static final int TAG_CANON_STATE1_EASY_SHOOTING_MODE = 0xC10B;
+    /**
+     * 0 = No Digital Zoom
+     * 1 = 2x
+     * 2 = 4x
+     */
+    public static final int TAG_CANON_STATE1_DIGITAL_ZOOM = 0xC10C;
+    /**
+     * 0 = Normal
+     * 1 = High
+     * 65535 = Low
+     */
+    public static final int TAG_CANON_STATE1_CONTRAST = 0xC10D;
+    /**
+     * 0 = Normal
+     * 1 = High
+     * 65535 = Low
+     */
+    public static final int TAG_CANON_STATE1_SATURATION = 0xC10E;
+    /**
+     * 0 = Normal
+     * 1 = High
+     * 65535 = Low
+     */
+    public static final int TAG_CANON_STATE1_SHARPNESS = 0xC10F;
+    /**
+     * 0 = Check ISOSpeedRatings EXIF tag for ISO Speed
+     * 15 = Auto ISO
+     * 16 = ISO 50
+     * 17 = ISO 100
+     * 18 = ISO 200
+     * 19 = ISO 400
+     */
+    public static final int TAG_CANON_STATE1_ISO = 0xC110;
+    /**
+     * 3 = Evaluative
+     * 4 = Partial
+     * 5 = Centre Weighted
+     */
+    public static final int TAG_CANON_STATE1_METERING_MODE = 0xC111;
+    /**
+     * 0 = Manual
+     * 1 = Auto
+     * 3 = Close-up (Macro)
+     * 8 = Locked (Pan Mode)
+     */
+    public static final int TAG_CANON_STATE1_FOCUS_TYPE = 0xC112;
+    /**
+     * 12288 = None (Manual Focus)
+     * 12289 = Auto Selected
+     * 12290 = Right
+     * 12291 = Centre
+     * 12292 = Left
+     */
+    public static final int TAG_CANON_STATE1_AF_POINT_SELECTED = 0xC113;
+    /**
+     * 0 = Easy Shooting (See Easy Shooting Mode)
+     * 1 = Program
+     * 2 = Tv-Priority
+     * 3 = Av-Priority
+     * 4 = Manual
+     * 5 = A-DEP
+     */
+    public static final int TAG_CANON_STATE1_EXPOSURE_MODE = 0xC114;
+    public static final int TAG_CANON_STATE1_UNKNOWN_7 = 0xC115;
+    public static final int TAG_CANON_STATE1_UNKNOWN_8 = 0xC116;
+    public static final int TAG_CANON_STATE1_LONG_FOCAL_LENGTH = 0xC117;
+    public static final int TAG_CANON_STATE1_SHORT_FOCAL_LENGTH = 0xC118;
+    public static final int TAG_CANON_STATE1_FOCAL_UNITS_PER_MM = 0xC119;
+    public static final int TAG_CANON_STATE1_UNKNOWN_9 = 0xC11A;
+    public static final int TAG_CANON_STATE1_UNKNOWN_10 = 0xC11B;
+    /**
+     * 0 = Flash Did Not Fire
+     * 1 = Flash Fired
+     */
+    public static final int TAG_CANON_STATE1_FLASH_ACTIVITY = 0xC11C;
+    public static final int TAG_CANON_STATE1_FLASH_DETAILS = 0xC11D;
+    public static final int TAG_CANON_STATE1_UNKNOWN_12 = 0xC11E;
+    public static final int TAG_CANON_STATE1_UNKNOWN_13 = 0xC11F;
+    /**
+     * 0 = Focus Mode: Single
+     * 1 = Focus Mode: Continuous
+     */
+    public static final int TAG_CANON_STATE1_FOCUS_MODE_2 = 0xC120;
+
+    /**
+     * 0 = Auto
+     * 1 = Sunny
+     * 2 = Cloudy
+     * 3 = Tungsten
+     * 4 = Flourescent
+     * 5 = Flash
+     * 6 = Custom
+     */
+    public static final int TAG_CANON_STATE2_WHITE_BALANCE = 0xC207;
+    public static final int TAG_CANON_STATE2_SEQUENCE_NUMBER = 0xC209;
+    public static final int TAG_CANON_STATE2_AF_POINT_USED = 0xC20E;
+    /**
+     * The value of this tag may be translated into a flash bias value, in EV.
+     *
+     * 0xffc0 = -2 EV
+     * 0xffcc = -1.67 EV
+     * 0xffd0 = -1.5 EV
+     * 0xffd4 = -1.33 EV
+     * 0xffe0 = -1 EV
+     * 0xffec = -0.67 EV
+     * 0xfff0 = -0.5 EV
+     * 0xfff4 = -0.33 EV
+     * 0x0000 = 0 EV
+     * 0x000c = 0.33 EV
+     * 0x0010 = 0.5 EV
+     * 0x0014 = 0.67 EV
+     * 0x0020 = 1 EV
+     * 0x002c = 1.33 EV
+     * 0x0030 = 1.5 EV
+     * 0x0034 = 1.67 EV
+     * 0x0040 = 2 EV 
+     */
+    public static final int TAG_CANON_STATE2_FLASH_BIAS = 0xC20F;
+    public static final int TAG_CANON_STATE2_AUTO_EXPOSURE_BRACKETING = 0xC210;
+    public static final int TAG_CANON_STATE2_AEB_BRACKET_VALUE = 0xC211;
+    public static final int TAG_CANON_STATE2_SUBJECT_DISTANCE = 0xC213;
+
+    /**
+     * Long Exposure Noise Reduction
+     * 0 = Off
+     * 1 = On
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_LONG_EXPOSURE_NOISE_REDUCTION = 0xC301;
+
+    /**
+     * Shutter/Auto Exposure-lock buttons
+     * 0 = AF/AE lock
+     * 1 = AE lock/AF
+     * 2 = AF/AF lock
+     * 3 = AE+release/AE+AF
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_SHUTTER_AUTO_EXPOSURE_LOCK_BUTTONS = 0xC302;
+
+    /**
+     * Mirror lockup
+     * 0 = Disable
+     * 1 = Enable
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_MIRROR_LOCKUP = 0xC303;
+
+    /**
+     * Tv/Av and exposure level
+     * 0 = 1/2 stop
+     * 1 = 1/3 stop
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_TV_AV_AND_EXPOSURE_LEVEL = 0xC304;
+
+    /**
+     * AF-assist light
+     * 0 = On (Auto)
+     * 1 = Off
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_AF_ASSIST_LIGHT = 0xC305;
+
+    /**
+     * Shutter speed in Av mode
+     * 0 = Automatic
+     * 1 = 1/200 (fixed)
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_SHUTTER_SPEED_IN_AV_MODE = 0xC306;
+
+    /**
+     * Auto-Exposure Bracketting sequence/auto cancellation
+     * 0 = 0,-,+ / Enabled
+     * 1 = 0,-,+ / Disabled
+     * 2 = -,0,+ / Enabled
+     * 3 = -,0,+ / Disabled
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_BRACKETTING = 0xC307;
+
+    /**
+     * Shutter Curtain Sync
+     * 0 = 1st Curtain Sync
+     * 1 = 2nd Curtain Sync
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_SHUTTER_CURTAIN_SYNC = 0xC308;
+
+    /**
+     * Lens Auto-Focus stop button Function Switch
+     * 0 = AF stop
+     * 1 = Operate AF
+     * 2 = Lock AE and start timer
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_AF_STOP = 0xC309;
+
+    /**
+     * Auto reduction of fill flash
+     * 0 = Enable
+     * 1 = Disable
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_FILL_FLASH_REDUCTION = 0xC30A;
+
+    /**
+     * Menu button return position
+     * 0 = Top
+     * 1 = Previous (volatile)
+     * 2 = Previous
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_MENU_BUTTON_RETURN = 0xC30B;
+
+    /**
+     * SET button function when shooting
+     * 0 = Not Assigned
+     * 1 = Change Quality
+     * 2 = Change ISO Speed
+     * 3 = Select Parameters
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_SET_BUTTON_FUNCTION = 0xC30C;
+
+    /**
+     * Sensor cleaning
+     * 0 = Disable
+     * 1 = Enable
+     */
+    public static final int TAG_CANON_CUSTOM_FUNCTION_SENSOR_CLEANING = 0xC30D;
+
+    // 9  A  B  C  D  E  F  10 11 12 13
+    // 9  10 11 12 13 14 15 16 17 18 19
+    protected static final HashMap _tagNameMap = new HashMap();
+
+    static
+    {
+        _tagNameMap.put(new Integer(TAG_CANON_FIRMWARE_VERSION), "Firmware Version");
+        _tagNameMap.put(new Integer(TAG_CANON_IMAGE_NUMBER), "Image Number");
+        _tagNameMap.put(new Integer(TAG_CANON_IMAGE_TYPE), "Image Type");
+        _tagNameMap.put(new Integer(TAG_CANON_OWNER_NAME), "Owner Name");
+        _tagNameMap.put(new Integer(TAG_CANON_UNKNOWN_1), "Makernote Unknown 1");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTIONS), "Custom Functions");
+        _tagNameMap.put(new Integer(TAG_CANON_SERIAL_NUMBER), "Camera Serial Number");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_AF_POINT_SELECTED), "AF Point Selected");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_CONTINUOUS_DRIVE_MODE), "Continuous Drive Mode");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_CONTRAST), "Contrast");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_EASY_SHOOTING_MODE), "Easy Shooting Mode");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_EXPOSURE_MODE), "Exposure Mode");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_FLASH_DETAILS), "Flash Details");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_FLASH_MODE), "Flash Mode");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_FOCAL_UNITS_PER_MM), "Focal Units per mm");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_FOCUS_MODE_1), "Focus Mode");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_FOCUS_MODE_2), "Focus Mode");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_IMAGE_SIZE), "Image Size");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_ISO), "Iso");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_LONG_FOCAL_LENGTH), "Long Focal Length");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_MACRO_MODE), "Macro Mode");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_METERING_MODE), "Metering Mode");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_SATURATION), "Saturation");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_SELF_TIMER_DELAY), "Self Timer Delay");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_SHARPNESS), "Sharpness");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_SHORT_FOCAL_LENGTH), "Short Focal Length");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_QUALITY), "Quality");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_UNKNOWN_2), "Unknown Camera State 2");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_UNKNOWN_3), "Unknown Camera State 3");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_UNKNOWN_4), "Unknown Camera State 4");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_DIGITAL_ZOOM), "Digital Zoom");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_FOCUS_TYPE), "Focus Type");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_UNKNOWN_7), "Unknown Camera State 7");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_UNKNOWN_8), "Unknown Camera State 8");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_UNKNOWN_9), "Unknown Camera State 9");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_UNKNOWN_10), "Unknown Camera State 10");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_FLASH_ACTIVITY), "Flash Activity");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_UNKNOWN_12), "Unknown Camera State 12");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE1_UNKNOWN_13), "Unknown Camera State 13");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE2_WHITE_BALANCE), "White Balance");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE2_SEQUENCE_NUMBER), "Sequence Number");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE2_AF_POINT_USED), "AF Point Used");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE2_FLASH_BIAS), "Flash Bias");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE2_AUTO_EXPOSURE_BRACKETING), "Auto Exposure Bracketing");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE2_AEB_BRACKET_VALUE), "AEB Bracket Value");
+        _tagNameMap.put(new Integer(TAG_CANON_STATE2_SUBJECT_DISTANCE), "Subject Distance");
+
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_LONG_EXPOSURE_NOISE_REDUCTION), "Long Exposure Noise Reduction");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_SHUTTER_AUTO_EXPOSURE_LOCK_BUTTONS), "Shutter/Auto Exposure-lock Buttons");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_MIRROR_LOCKUP), "Mirror Lockup");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_TV_AV_AND_EXPOSURE_LEVEL), "Tv/Av And Exposure Level");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_AF_ASSIST_LIGHT), "AF-Assist Light");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_SHUTTER_SPEED_IN_AV_MODE), "Shutter Speed in Av Mode");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_BRACKETTING), "Auto-Exposure Bracketting Sequence/Auto Cancellation");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_SHUTTER_CURTAIN_SYNC), "Shutter Curtain Sync");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_AF_STOP), "Lens Auto-Focus Stop Button Function Switch");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_FILL_FLASH_REDUCTION), "Auto Reduction of Fill Flash");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_MENU_BUTTON_RETURN), "Menu Button Return Position");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_SET_BUTTON_FUNCTION), "SET Button Function When Shooting");
+        _tagNameMap.put(new Integer(TAG_CANON_CUSTOM_FUNCTION_SENSOR_CLEANING), "Sensor Cleaning");
+    }
+
+    public CanonMakernoteDirectory()
+    {
+        this.setDescriptor(new CanonMakernoteDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "Canon Makernote";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return _tagNameMap;
+    }
+
+    /**
+     * We need special handling for selected tags.
+     * @param tagType
+     * @param ints
+     */
+    public void setIntArray(int tagType, int[] ints)
+    {
+        if (tagType == TAG_CANON_CAMERA_STATE_1) {
+            // this single tag has multiple values within
+            int subTagTypeBase = 0xC100;
+            // we intentionally skip the first array member
+            for (int i = 1; i < ints.length; i++) {
+                setInt(subTagTypeBase + i, ints[i]);
+            }
+        } else if (tagType == TAG_CANON_CAMERA_STATE_2) {
+            // this single tag has multiple values within
+            int subTagTypeBase = 0xC200;
+            // we intentionally skip the first array member
+            for (int i = 1; i < ints.length; i++) {
+                setInt(subTagTypeBase + i, ints[i]);
+            }
+        } if (tagType == TAG_CANON_CUSTOM_FUNCTIONS) {
+            // this single tag has multiple values within
+            int subTagTypeBase = 0xC300;
+            // we intentionally skip the first array member
+            for (int i = 1; i < ints.length; i++) {
+                setInt(subTagTypeBase + i + 1, ints[i] & 0x0F);
+            }
+        } else {
+            // no special handling...
+            super.setIntArray(tagType, ints);
+        }
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/CasioType1MakernoteDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/CasioType1MakernoteDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/CasioType1MakernoteDescriptor.java	(revision 4231)
@@ -0,0 +1,273 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:12:05 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ *
+ */
+public class CasioType1MakernoteDescriptor extends TagDescriptor
+{
+    public CasioType1MakernoteDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType) {
+            case CasioType1MakernoteDirectory.TAG_CASIO_RECORDING_MODE:
+                return getRecordingModeDescription();
+            case CasioType1MakernoteDirectory.TAG_CASIO_QUALITY:
+                return getQualityDescription();
+            case CasioType1MakernoteDirectory.TAG_CASIO_FOCUSING_MODE:
+                return getFocusingModeDescription();
+            case CasioType1MakernoteDirectory.TAG_CASIO_FLASH_MODE:
+                return getFlashModeDescription();
+            case CasioType1MakernoteDirectory.TAG_CASIO_FLASH_INTENSITY:
+                return getFlashIntensityDescription();
+            case CasioType1MakernoteDirectory.TAG_CASIO_OBJECT_DISTANCE:
+                return getObjectDistanceDescription();
+            case CasioType1MakernoteDirectory.TAG_CASIO_WHITE_BALANCE:
+                return getWhiteBalanceDescription();
+            case CasioType1MakernoteDirectory.TAG_CASIO_DIGITAL_ZOOM:
+                return getDigitalZoomDescription();
+            case CasioType1MakernoteDirectory.TAG_CASIO_SHARPNESS:
+                return getSharpnessDescription();
+            case CasioType1MakernoteDirectory.TAG_CASIO_CONTRAST:
+                return getContrastDescription();
+            case CasioType1MakernoteDirectory.TAG_CASIO_SATURATION:
+                return getSaturationDescription();
+            case CasioType1MakernoteDirectory.TAG_CASIO_CCD_SENSITIVITY:
+                return getCcdSensitivityDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getCcdSensitivityDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType1MakernoteDirectory.TAG_CASIO_CCD_SENSITIVITY)) return null;
+        int value = _directory.getInt(CasioType1MakernoteDirectory.TAG_CASIO_CCD_SENSITIVITY);
+        switch (value) {
+            // these four for QV3000
+            case 64:
+                return "Normal";
+            case 125:
+                return "+1.0";
+            case 250:
+                return "+2.0";
+            case 244:
+                return "+3.0";
+                // these two for QV8000/2000
+            case 80:
+                return "Normal (ISO 80 equivalent)";
+            case 100:
+                return "High";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getSaturationDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType1MakernoteDirectory.TAG_CASIO_SATURATION)) return null;
+        int value = _directory.getInt(CasioType1MakernoteDirectory.TAG_CASIO_SATURATION);
+        switch (value) {
+            case 0:
+                return "Normal";
+            case 1:
+                return "Low";
+            case 2:
+                return "High";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getContrastDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType1MakernoteDirectory.TAG_CASIO_CONTRAST)) return null;
+        int value = _directory.getInt(CasioType1MakernoteDirectory.TAG_CASIO_CONTRAST);
+        switch (value) {
+            case 0:
+                return "Normal";
+            case 1:
+                return "Low";
+            case 2:
+                return "High";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getSharpnessDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType1MakernoteDirectory.TAG_CASIO_SHARPNESS)) return null;
+        int value = _directory.getInt(CasioType1MakernoteDirectory.TAG_CASIO_SHARPNESS);
+        switch (value) {
+            case 0:
+                return "Normal";
+            case 1:
+                return "Soft";
+            case 2:
+                return "Hard";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getDigitalZoomDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType1MakernoteDirectory.TAG_CASIO_DIGITAL_ZOOM)) return null;
+        int value = _directory.getInt(CasioType1MakernoteDirectory.TAG_CASIO_DIGITAL_ZOOM);
+        switch (value) {
+            case 0x10000:
+                return "No digital zoom";
+            case 0x10001:
+                return "2x digital zoom";
+            case 0x20000:
+                return "2x digital zoom";
+            case 0x40000:
+                return "4x digital zoom";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getWhiteBalanceDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType1MakernoteDirectory.TAG_CASIO_WHITE_BALANCE)) return null;
+        int value = _directory.getInt(CasioType1MakernoteDirectory.TAG_CASIO_WHITE_BALANCE);
+        switch (value) {
+            case 1:
+                return "Auto";
+            case 2:
+                return "Tungsten";
+            case 3:
+                return "Daylight";
+            case 4:
+                return "Flourescent";
+            case 5:
+                return "Shade";
+            case 129:
+                return "Manual";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getObjectDistanceDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType1MakernoteDirectory.TAG_CASIO_OBJECT_DISTANCE)) return null;
+        int value = _directory.getInt(CasioType1MakernoteDirectory.TAG_CASIO_OBJECT_DISTANCE);
+        return value + " mm";
+    }
+
+    public String getFlashIntensityDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType1MakernoteDirectory.TAG_CASIO_FLASH_INTENSITY)) return null;
+        int value = _directory.getInt(CasioType1MakernoteDirectory.TAG_CASIO_FLASH_INTENSITY);
+        switch (value) {
+            case 11:
+                return "Weak";
+            case 13:
+                return "Normal";
+            case 15:
+                return "Strong";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFlashModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType1MakernoteDirectory.TAG_CASIO_FLASH_MODE)) return null;
+        int value = _directory.getInt(CasioType1MakernoteDirectory.TAG_CASIO_FLASH_MODE);
+        switch (value) {
+            case 1:
+                return "Auto";
+            case 2:
+                return "On";
+            case 3:
+                return "Off";
+            case 4:
+                // this documented as additional value for off here:
+                // http://www.ozhiker.com/electronics/pjmt/jpeg_info/casio_mn.html
+                return "Red eye reduction";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFocusingModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType1MakernoteDirectory.TAG_CASIO_FOCUSING_MODE)) return null;
+        int value = _directory.getInt(CasioType1MakernoteDirectory.TAG_CASIO_FOCUSING_MODE);
+        switch (value) {
+            case 2:
+                return "Macro";
+            case 3:
+                return "Auto focus";
+            case 4:
+                return "Manual focus";
+            case 5:
+                return "Infinity";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getQualityDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType1MakernoteDirectory.TAG_CASIO_QUALITY)) return null;
+        int value = _directory.getInt(CasioType1MakernoteDirectory.TAG_CASIO_QUALITY);
+        switch (value) {
+            case 1:
+                return "Economy";
+            case 2:
+                return "Normal";
+            case 3:
+                return "Fine";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getRecordingModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType1MakernoteDirectory.TAG_CASIO_RECORDING_MODE)) return null;
+        int value = _directory.getInt(CasioType1MakernoteDirectory.TAG_CASIO_RECORDING_MODE);
+        switch (value) {
+            case 1:
+                return "Single shutter";
+            case 2:
+                return "Panorama";
+            case 3:
+                return "Night scene";
+            case 4:
+                return "Portrait";
+            case 5:
+                return "Landscape";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/CasioType1MakernoteDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/CasioType1MakernoteDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/CasioType1MakernoteDirectory.java	(revision 4231)
@@ -0,0 +1,90 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:10:47 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ * A standard TIFF IFD directory but always uses Motorola (Big-Endian) Byte Alignment.
+ * Makernote data begins immediately (no header).
+ */
+public class CasioType1MakernoteDirectory extends Directory
+{
+    public static final int TAG_CASIO_RECORDING_MODE = 0x0001;
+    public static final int TAG_CASIO_QUALITY = 0x0002;
+    public static final int TAG_CASIO_FOCUSING_MODE = 0x0003;
+    public static final int TAG_CASIO_FLASH_MODE = 0x0004;
+    public static final int TAG_CASIO_FLASH_INTENSITY = 0x0005;
+    public static final int TAG_CASIO_OBJECT_DISTANCE = 0x0006;
+    public static final int TAG_CASIO_WHITE_BALANCE = 0x0007;
+    public static final int TAG_CASIO_UNKNOWN_1 = 0x0008;
+    public static final int TAG_CASIO_UNKNOWN_2 = 0x0009;
+    public static final int TAG_CASIO_DIGITAL_ZOOM = 0x000A;
+    public static final int TAG_CASIO_SHARPNESS = 0x000B;
+    public static final int TAG_CASIO_CONTRAST = 0x000C;
+    public static final int TAG_CASIO_SATURATION = 0x000D;
+    public static final int TAG_CASIO_UNKNOWN_3 = 0x000E;
+    public static final int TAG_CASIO_UNKNOWN_4 = 0x000F;
+    public static final int TAG_CASIO_UNKNOWN_5 = 0x0010;
+    public static final int TAG_CASIO_UNKNOWN_6 = 0x0011;
+    public static final int TAG_CASIO_UNKNOWN_7 = 0x0012;
+    public static final int TAG_CASIO_UNKNOWN_8 = 0x0013;
+    public static final int TAG_CASIO_CCD_SENSITIVITY = 0x0014;
+
+    protected static final HashMap<Integer, String> tagNameMap = new HashMap<Integer, String>();
+
+    static
+    {
+        tagNameMap.put(new Integer(TAG_CASIO_CCD_SENSITIVITY), "CCD Sensitivity");
+        tagNameMap.put(new Integer(TAG_CASIO_CONTRAST), "Contrast");
+        tagNameMap.put(new Integer(TAG_CASIO_DIGITAL_ZOOM), "Digital Zoom");
+        tagNameMap.put(new Integer(TAG_CASIO_FLASH_INTENSITY), "Flash Intensity");
+        tagNameMap.put(new Integer(TAG_CASIO_FLASH_MODE), "Flash Mode");
+        tagNameMap.put(new Integer(TAG_CASIO_FOCUSING_MODE), "Focussing Mode");
+        tagNameMap.put(new Integer(TAG_CASIO_OBJECT_DISTANCE), "Object Distance");
+        tagNameMap.put(new Integer(TAG_CASIO_QUALITY), "Quality");
+        tagNameMap.put(new Integer(TAG_CASIO_RECORDING_MODE), "Recording Mode");
+        tagNameMap.put(new Integer(TAG_CASIO_SATURATION), "Saturation");
+        tagNameMap.put(new Integer(TAG_CASIO_SHARPNESS), "Sharpness");
+        tagNameMap.put(new Integer(TAG_CASIO_UNKNOWN_1), "Makernote Unknown 1");
+        tagNameMap.put(new Integer(TAG_CASIO_UNKNOWN_2), "Makernote Unknown 2");
+        tagNameMap.put(new Integer(TAG_CASIO_UNKNOWN_3), "Makernote Unknown 3");
+        tagNameMap.put(new Integer(TAG_CASIO_UNKNOWN_4), "Makernote Unknown 4");
+        tagNameMap.put(new Integer(TAG_CASIO_UNKNOWN_5), "Makernote Unknown 5");
+        tagNameMap.put(new Integer(TAG_CASIO_UNKNOWN_6), "Makernote Unknown 6");
+        tagNameMap.put(new Integer(TAG_CASIO_UNKNOWN_7), "Makernote Unknown 7");
+        tagNameMap.put(new Integer(TAG_CASIO_UNKNOWN_8), "Makernote Unknown 8");
+        tagNameMap.put(new Integer(TAG_CASIO_WHITE_BALANCE), "White Balance");
+    }
+
+    public CasioType1MakernoteDirectory()
+    {
+        this.setDescriptor(new CasioType1MakernoteDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "Casio Makernote";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return tagNameMap;
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/CasioType2MakernoteDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/CasioType2MakernoteDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/CasioType2MakernoteDescriptor.java	(revision 4231)
@@ -0,0 +1,430 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:12:05 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ *
+ */
+public class CasioType2MakernoteDescriptor extends TagDescriptor
+{
+    public CasioType2MakernoteDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType) {
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_THUMBNAIL_DIMENSIONS:
+                return getThumbnailDimensionsDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_THUMBNAIL_SIZE:
+                return getThumbnailSizeDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_THUMBNAIL_OFFSET:
+                return getThumbnailOffsetDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_QUALITY_MODE:
+                return getQualityModeDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_IMAGE_SIZE:
+                return getImageSizeDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FOCUS_MODE_1:
+                return getFocusMode1Description();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_ISO_SENSITIVITY:
+                return getIsoSensitivityDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_WHITE_BALANCE_1:
+                return getWhiteBalance1Description();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FOCAL_LENGTH:
+                return getFocalLengthDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_SATURATION:
+                return getSaturationDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_CONTRAST:
+                return getContrastDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_SHARPNESS:
+                return getSharpnessDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_PRINT_IMAGE_MATCHING_INFO:
+                return getPrintImageMatchingInfoDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_CASIO_PREVIEW_THUMBNAIL:
+                return getCasioPreviewThumbnailDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_WHITE_BALANCE_BIAS:
+                return getWhiteBalanceBiasDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_WHITE_BALANCE_2:
+                return getWhiteBalance2Description();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_OBJECT_DISTANCE:
+                return getObjectDistanceDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FLASH_DISTANCE:
+                return getFlashDistanceDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_RECORD_MODE:
+                return getRecordModeDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_SELF_TIMER:
+                return getSelfTimerDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_QUALITY:
+                return getQualityDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FOCUS_MODE_2:
+                return getFocusMode2Description();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_TIME_ZONE:
+                return getTimeZoneDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_BESTSHOT_MODE:
+                return getBestShotModeDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_CCD_ISO_SENSITIVITY:
+                return getCcdIsoSensitivityDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_COLOUR_MODE:
+                return getColourModeDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_ENHANCEMENT:
+                return getEnhancementDescription();
+            case CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FILTER:
+                return getFilterDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getFilterDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FILTER)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FILTER);
+        switch (value) {
+            case 0:
+                return "Off";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getEnhancementDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_ENHANCEMENT)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_ENHANCEMENT);
+        switch (value) {
+            case 0:
+                return "Off";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getColourModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_COLOUR_MODE)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_COLOUR_MODE);
+        switch (value) {
+            case 0:
+                return "Off";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getCcdIsoSensitivityDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_CCD_ISO_SENSITIVITY)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_CCD_ISO_SENSITIVITY);
+        switch (value) {
+            case 0:
+                return "Off";
+            case 1:
+                return "On";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getBestShotModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_BESTSHOT_MODE)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_BESTSHOT_MODE);
+        switch (value) {
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getTimeZoneDescription()
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_TIME_ZONE)) return null;
+        return _directory.getString(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_TIME_ZONE);
+    }
+
+    public String getFocusMode2Description() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FOCUS_MODE_2)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FOCUS_MODE_2);
+        switch (value) {
+            case 1:
+                return "Fixation";
+            case 6:
+                return "Multi-Area Focus";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getQualityDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_QUALITY)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_QUALITY);
+        switch (value) {
+            case 3:
+                return "Fine";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getSelfTimerDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_SELF_TIMER)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_SELF_TIMER);
+        switch (value) {
+            case 1:
+                return "Off";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getRecordModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_RECORD_MODE)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_RECORD_MODE);
+        switch (value) {
+            case 2:
+                return "Normal";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFlashDistanceDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FLASH_DISTANCE)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FLASH_DISTANCE);
+        switch (value) {
+            case 0:
+                return "Off";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getObjectDistanceDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_OBJECT_DISTANCE)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_OBJECT_DISTANCE);
+        return Integer.toString(value) + " mm";
+    }
+
+    public String getWhiteBalance2Description() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_WHITE_BALANCE_2)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_WHITE_BALANCE_2);
+        switch (value) {
+            case 0:
+                return "Manual";
+            case 1:
+                return "Auto"; // unsure about this
+            case 4:
+                return "Flash"; // unsure about this
+            case 12:
+                return "Flash";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getWhiteBalanceBiasDescription()
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_WHITE_BALANCE_BIAS)) return null;
+        return _directory.getString(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_WHITE_BALANCE_BIAS);
+    }
+
+    public String getCasioPreviewThumbnailDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_CASIO_PREVIEW_THUMBNAIL)) return null;
+        final byte[] bytes = _directory.getByteArray(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_CASIO_PREVIEW_THUMBNAIL);
+        return "<" + bytes.length + " bytes of image data>";
+    }
+
+    public String getPrintImageMatchingInfoDescription()
+    {
+        // TODO research PIM specification http://www.ozhiker.com/electronics/pjmt/jpeg_info/pim.html
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_PRINT_IMAGE_MATCHING_INFO)) return null;
+        return _directory.getString(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_PRINT_IMAGE_MATCHING_INFO);
+    }
+
+    public String getSharpnessDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_SHARPNESS)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_SHARPNESS);
+        switch (value) {
+            case 0:
+                return "-1";
+            case 1:
+                return "Normal";
+            case 2:
+                return "+1";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getContrastDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_CONTRAST)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_CONTRAST);
+        switch (value) {
+            case 0:
+                return "-1";
+            case 1:
+                return "Normal";
+            case 2:
+                return "+1";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getSaturationDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_SATURATION)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_SATURATION);
+        switch (value) {
+            case 0:
+                return "-1";
+            case 1:
+                return "Normal";
+            case 2:
+                return "+1";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFocalLengthDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FOCAL_LENGTH)) return null;
+        double value = _directory.getDouble(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FOCAL_LENGTH);
+        return Double.toString(value / 10d) + " mm";
+    }
+
+    public String getWhiteBalance1Description() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_WHITE_BALANCE_1)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_WHITE_BALANCE_1);
+        switch (value) {
+            case 0:
+                return "Auto";
+            case 1:
+                return "Daylight";
+            case 2:
+                return "Shade";
+            case 3:
+                return "Tungsten";
+            case 4:
+                return "Flourescent";
+            case 5:
+                return "Manual";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getIsoSensitivityDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_ISO_SENSITIVITY)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_ISO_SENSITIVITY);
+        switch (value) {
+            case 3:
+                return "50";
+            case 4:
+                return "64";
+            case 6:
+                return "100";
+            case 9:
+                return "200";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFocusMode1Description() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FOCUS_MODE_1)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_FOCUS_MODE_1);
+        switch (value) {
+            case 0:
+                return "Normal";
+            case 1:
+                return "Macro";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getImageSizeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_IMAGE_SIZE)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_IMAGE_SIZE);
+        switch (value) {
+            case 0:  return "640 x 480 pixels";
+            case 4:  return "1600 x 1200 pixels";
+            case 5:  return "2048 x 1536 pixels";
+            case 20: return "2288 x 1712 pixels";
+            case 21: return "2592 x 1944 pixels";
+            case 22: return "2304 x 1728 pixels";
+            case 36: return "3008 x 2008 pixels";
+            default: return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getQualityModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_QUALITY_MODE)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_QUALITY_MODE);
+        switch (value) {
+            case 1:
+                return "Fine";
+            case 2:
+                return "Super Fine";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getThumbnailOffsetDescription()
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_THUMBNAIL_OFFSET)) return null;
+        return _directory.getString(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_THUMBNAIL_OFFSET);
+    }
+
+    public String getThumbnailSizeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_THUMBNAIL_SIZE)) return null;
+        int value = _directory.getInt(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_THUMBNAIL_SIZE);
+        return Integer.toString(value) + " bytes";
+    }
+
+    public String getThumbnailDimensionsDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_THUMBNAIL_DIMENSIONS)) return null;
+        int[] dimensions = _directory.getIntArray(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_THUMBNAIL_DIMENSIONS);
+        if (dimensions.length!=2)
+            return _directory.getString(CasioType2MakernoteDirectory.TAG_CASIO_TYPE2_THUMBNAIL_DIMENSIONS);
+        return dimensions[0] + " x " + dimensions[1] + " pixels";
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/CasioType2MakernoteDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/CasioType2MakernoteDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/CasioType2MakernoteDirectory.java	(revision 4231)
@@ -0,0 +1,218 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:10:47 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ * A standard TIFF IFD directory but always uses Motorola (Big-Endian) Byte Alignment.
+ * Makernote data begins after a 6-byte header: "QVC\x00\x00\x00"
+ */
+public class CasioType2MakernoteDirectory extends Directory
+{
+    /**
+     * 2 values - x,y dimensions in pixels.
+     */
+    public static final int TAG_CASIO_TYPE2_THUMBNAIL_DIMENSIONS = 0x0002;
+    /**
+     * Size in bytes
+     */
+    public static final int TAG_CASIO_TYPE2_THUMBNAIL_SIZE = 0x0003;
+    /**
+     * Offset of Preview Thumbnail
+     */
+    public static final int TAG_CASIO_TYPE2_THUMBNAIL_OFFSET = 0x0004;
+    /**
+     * 1 = Fine
+     * 2 = Super Fine
+     */
+    public static final int TAG_CASIO_TYPE2_QUALITY_MODE = 0x0008;
+    /**
+     * 0 = 640 x 480 pixels
+     * 4 = 1600 x 1200 pixels
+     * 5 = 2048 x 1536 pixels
+     * 20 = 2288 x 1712 pixels
+     * 21 = 2592 x 1944 pixels
+     * 22 = 2304 x 1728 pixels
+     * 36 = 3008 x 2008 pixels
+     */
+    public static final int TAG_CASIO_TYPE2_IMAGE_SIZE = 0x0009;
+    /**
+     * 0 = Normal
+     * 1 = Macro
+     */
+    public static final int TAG_CASIO_TYPE2_FOCUS_MODE_1 = 0x000D;
+    /**
+     * 3 = 50
+     * 4 = 64
+     * 6 = 100
+     * 9 = 200
+     */
+    public static final int TAG_CASIO_TYPE2_ISO_SENSITIVITY = 0x0014;
+    /**
+     * 0 = Auto
+     * 1 = Daylight
+     * 2 = Shade
+     * 3 = Tungsten
+     * 4 = Fluorescent
+     * 5 = Manual
+     */
+    public static final int TAG_CASIO_TYPE2_WHITE_BALANCE_1 = 0x0019;
+    /**
+     * Units are tenths of a millimetre
+     */
+    public static final int TAG_CASIO_TYPE2_FOCAL_LENGTH = 0x001D;
+    /**
+     * 0 = -1
+     * 1 = Normal
+     * 2 = +1
+     */
+    public static final int TAG_CASIO_TYPE2_SATURATION = 0x001F;
+    /**
+     * 0 = -1
+     * 1 = Normal
+     * 2 = +1
+     */
+    public static final int TAG_CASIO_TYPE2_CONTRAST = 0x0020;
+    /**
+     * 0 = -1
+     * 1 = Normal
+     * 2 = +1
+     */
+    public static final int TAG_CASIO_TYPE2_SHARPNESS = 0x0021;
+    /**
+     * See PIM specification here: http://www.ozhiker.com/electronics/pjmt/jpeg_info/pim.html
+     */
+    public static final int TAG_CASIO_TYPE2_PRINT_IMAGE_MATCHING_INFO = 0x0E00;
+    /**
+     * Alternate thumbnail offset
+     */
+    public static final int TAG_CASIO_TYPE2_CASIO_PREVIEW_THUMBNAIL = 0x2000;
+    /**
+     *
+     */
+    public static final int TAG_CASIO_TYPE2_WHITE_BALANCE_BIAS = 0x2011;
+    /**
+     * 12 = Flash
+     * 0 = Manual
+     * 1 = Auto?
+     * 4 = Flash?
+     */
+    public static final int TAG_CASIO_TYPE2_WHITE_BALANCE_2 = 0x2012;
+    /**
+     * Units are millimetres
+     */
+    public static final int TAG_CASIO_TYPE2_OBJECT_DISTANCE = 0x2022;
+    /**
+     * 0 = Off
+     */
+    public static final int TAG_CASIO_TYPE2_FLASH_DISTANCE = 0x2034;
+    /**
+     * 2 = Normal Mode
+     */
+    public static final int TAG_CASIO_TYPE2_RECORD_MODE = 0x3000;
+    /**
+     * 1 = Off?
+     */
+    public static final int TAG_CASIO_TYPE2_SELF_TIMER = 0x3001;
+    /**
+     * 3 = Fine
+     */
+    public static final int TAG_CASIO_TYPE2_QUALITY = 0x3002;
+    /**
+     * 1 = Fixation
+     * 6 = Multi-Area Auto Focus
+     */
+    public static final int TAG_CASIO_TYPE2_FOCUS_MODE_2 = 0x3003;
+    /**
+     * (string)
+     */
+    public static final int TAG_CASIO_TYPE2_TIME_ZONE = 0x3006;
+    /**
+     *
+     */
+    public static final int TAG_CASIO_TYPE2_BESTSHOT_MODE = 0x3007;
+    /**
+     * 0 = Off
+     * 1 = On?
+     */
+    public static final int TAG_CASIO_TYPE2_CCD_ISO_SENSITIVITY = 0x3014;
+    /**
+     * 0 = Off
+     */
+    public static final int TAG_CASIO_TYPE2_COLOUR_MODE = 0x3015;
+    /**
+     * 0 = Off
+     */
+    public static final int TAG_CASIO_TYPE2_ENHANCEMENT = 0x3016;
+    /**
+     * 0 = Off
+     */
+    public static final int TAG_CASIO_TYPE2_FILTER = 0x3017;
+
+    protected static final HashMap tagNameMap = new HashMap();
+
+    static
+    {
+        // TODO add names
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_THUMBNAIL_DIMENSIONS), "Thumbnail Dimensions");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_THUMBNAIL_SIZE), "Thumbnail Size");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_THUMBNAIL_OFFSET), "Thumbnail Offset");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_QUALITY_MODE), "Quality Mode");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_IMAGE_SIZE), "Image Size");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_FOCUS_MODE_1), "Focus Mode");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_ISO_SENSITIVITY), "ISO Sensitivity");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_WHITE_BALANCE_1), "White Balance");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_FOCAL_LENGTH), "Focal Length");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_SATURATION), "Saturation");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_CONTRAST), "Contrast");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_SHARPNESS), "Sharpness");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_PRINT_IMAGE_MATCHING_INFO), "Print Image Matching (PIM) Info");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_CASIO_PREVIEW_THUMBNAIL), "Casio Preview Thumbnail");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_WHITE_BALANCE_BIAS), "White Balance Bias");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_WHITE_BALANCE_2), "White Balance");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_OBJECT_DISTANCE), "Object Distance");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_FLASH_DISTANCE), "Flash Distance");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_RECORD_MODE), "Record Mode");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_SELF_TIMER), "Self Timer");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_QUALITY), "Quality");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_FOCUS_MODE_2), "Focus Mode");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_TIME_ZONE), "Time Zone");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_BESTSHOT_MODE), "BestShot Mode");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_CCD_ISO_SENSITIVITY), "CCD ISO Sensitivity");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_COLOUR_MODE), "Colour Mode");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_ENHANCEMENT), "Enhancement");
+        tagNameMap.put(new Integer(TAG_CASIO_TYPE2_FILTER), "Filter");
+    }
+
+    public CasioType2MakernoteDirectory()
+    {
+        this.setDescriptor(new CasioType2MakernoteDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "Casio Makernote";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return tagNameMap;
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/DataFormat.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/DataFormat.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/DataFormat.java	(revision 4231)
@@ -0,0 +1,76 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.MetadataException;
+
+/**
+ * An enumeration of data formats used in the TIFF IFDs.
+ */
+public class DataFormat
+{
+    public static final DataFormat BYTE = new DataFormat("BYTE", 1);
+    public static final DataFormat STRING = new DataFormat("STRING", 2);
+    public static final DataFormat USHORT = new DataFormat("USHORT", 3);
+    public static final DataFormat ULONG = new DataFormat("ULONG", 4);
+    public static final DataFormat URATIONAL = new DataFormat("URATIONAL", 5);
+    public static final DataFormat SBYTE = new DataFormat("SBYTE", 6);
+    public static final DataFormat UNDEFINED = new DataFormat("UNDEFINED", 7);
+    public static final DataFormat SSHORT = new DataFormat("SSHORT", 8);
+    public static final DataFormat SLONG = new DataFormat("SLONG", 9);
+    public static final DataFormat SRATIONAL = new DataFormat("SRATIONAL", 10);
+    public static final DataFormat SINGLE = new DataFormat("SINGLE", 11);
+    public static final DataFormat DOUBLE = new DataFormat("DOUBLE", 12);
+
+    private final String myName;
+    private final int value;
+
+    public static DataFormat fromValue(int value) throws MetadataException
+    {
+        switch (value)
+        {
+            case 1:  return BYTE;
+            case 2:  return STRING;
+            case 3:  return USHORT;
+            case 4:  return ULONG;
+            case 5:  return URATIONAL;
+            case 6:  return SBYTE;
+            case 7:  return UNDEFINED;
+            case 8:  return SSHORT;
+            case 9:  return SLONG;
+            case 10: return SRATIONAL;
+            case 11: return SINGLE;
+            case 12: return DOUBLE;
+        }
+
+        throw new MetadataException("value '"+value+"' does not represent a known data format.");
+    }
+
+    private DataFormat(String name, int value)
+    {
+        myName = name;
+        this.value = value;
+    }
+
+    public int getValue()
+    {
+        return value;
+    }
+
+    public String toString()
+    {
+        return myName;
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/ExifDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/ExifDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/ExifDescriptor.java	(revision 4231)
@@ -0,0 +1,1121 @@
+/*
+ * ExifDescriptor.java
+ *
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 12-Nov-2002 22:27:15 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.imaging.PhotographicConversions;
+import com.drew.lang.Rational;
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+import java.io.UnsupportedEncodingException;
+import java.text.DecimalFormat;
+
+/**
+ * Contains all logic for the presentation of raw Exif data, as stored in ExifDirectory.  Use
+ * this class to provide human-readable descriptions of tag values.
+ */
+public class ExifDescriptor extends TagDescriptor
+{
+    /**
+     * Dictates whether rational values will be represented in decimal format in instances
+     * where decimal notation is elegant (such as 1/2 -> 0.5, but not 1/3).
+     */
+    private boolean _allowDecimalRepresentationOfRationals = true;
+
+    private static final java.text.DecimalFormat SimpleDecimalFormatter = new DecimalFormat("0.#");
+
+    public ExifDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    // Note for the potential addition of brightness presentation in eV:
+    // Brightness of taken subject. To calculate Exposure(Ev) from BrigtnessValue(Bv),
+    // you must add SensitivityValue(Sv).
+    // Ev=BV+Sv   Sv=log2(ISOSpeedRating/3.125)
+    // ISO100:Sv=5, ISO200:Sv=6, ISO400:Sv=7, ISO125:Sv=5.32.
+
+    /**
+     * Returns a descriptive value of the the specified tag for this image.
+     * Where possible, known values will be substituted here in place of the raw
+     * tokens actually kept in the Exif segment.  If no substitution is
+     * available, the value provided by getString(int) will be returned.
+     * @param tagType the tag to find a description for
+     * @return a description of the image's value for the specified tag, or
+     *         <code>null</code> if the tag hasn't been defined.
+     */
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType) {
+            case ExifDirectory.TAG_ORIENTATION:
+                return getOrientationDescription();
+            case ExifDirectory.TAG_NEW_SUBFILE_TYPE:
+                return getNewSubfileTypeDescription();
+            case ExifDirectory.TAG_SUBFILE_TYPE:
+                return getSubfileTypeDescription();
+            case ExifDirectory.TAG_THRESHOLDING:
+                return getThresholdingDescription();
+            case ExifDirectory.TAG_FILL_ORDER:
+                return getFillOrderDescription();
+            case ExifDirectory.TAG_RESOLUTION_UNIT:
+                return getResolutionDescription();
+            case ExifDirectory.TAG_YCBCR_POSITIONING:
+                return getYCbCrPositioningDescription();
+            case ExifDirectory.TAG_EXPOSURE_TIME:
+                return getExposureTimeDescription();
+            case ExifDirectory.TAG_SHUTTER_SPEED:
+                return getShutterSpeedDescription();
+            case ExifDirectory.TAG_FNUMBER:
+                return getFNumberDescription();
+            case ExifDirectory.TAG_X_RESOLUTION:
+                return getXResolutionDescription();
+            case ExifDirectory.TAG_Y_RESOLUTION:
+                return getYResolutionDescription();
+            case ExifDirectory.TAG_THUMBNAIL_OFFSET:
+                return getThumbnailOffsetDescription();
+            case ExifDirectory.TAG_THUMBNAIL_LENGTH:
+                return getThumbnailLengthDescription();
+            case ExifDirectory.TAG_COMPRESSION_LEVEL:
+                return getCompressionLevelDescription();
+            case ExifDirectory.TAG_SUBJECT_DISTANCE:
+                return getSubjectDistanceDescription();
+            case ExifDirectory.TAG_METERING_MODE:
+                return getMeteringModeDescription();
+            case ExifDirectory.TAG_WHITE_BALANCE:
+                return getWhiteBalanceDescription();
+            case ExifDirectory.TAG_FLASH:
+                return getFlashDescription();
+            case ExifDirectory.TAG_FOCAL_LENGTH:
+                return getFocalLengthDescription();
+            case ExifDirectory.TAG_COLOR_SPACE:
+                return getColorSpaceDescription();
+            case ExifDirectory.TAG_EXIF_IMAGE_WIDTH:
+                return getExifImageWidthDescription();
+            case ExifDirectory.TAG_EXIF_IMAGE_HEIGHT:
+                return getExifImageHeightDescription();
+            case ExifDirectory.TAG_FOCAL_PLANE_UNIT:
+                return getFocalPlaneResolutionUnitDescription();
+            case ExifDirectory.TAG_FOCAL_PLANE_X_RES:
+                return getFocalPlaneXResolutionDescription();
+            case ExifDirectory.TAG_FOCAL_PLANE_Y_RES:
+                return getFocalPlaneYResolutionDescription();
+            case ExifDirectory.TAG_THUMBNAIL_IMAGE_WIDTH:
+                return getThumbnailImageWidthDescription();
+            case ExifDirectory.TAG_THUMBNAIL_IMAGE_HEIGHT:
+                return getThumbnailImageHeightDescription();
+            case ExifDirectory.TAG_BITS_PER_SAMPLE:
+                return getBitsPerSampleDescription();
+            case ExifDirectory.TAG_COMPRESSION:
+                return getCompressionDescription();
+            case ExifDirectory.TAG_PHOTOMETRIC_INTERPRETATION:
+                return getPhotometricInterpretationDescription();
+            case ExifDirectory.TAG_ROWS_PER_STRIP:
+                return getRowsPerStripDescription();
+            case ExifDirectory.TAG_STRIP_BYTE_COUNTS:
+                return getStripByteCountsDescription();
+            case ExifDirectory.TAG_SAMPLES_PER_PIXEL:
+                return getSamplesPerPixelDescription();
+            case ExifDirectory.TAG_PLANAR_CONFIGURATION:
+                return getPlanarConfigurationDescription();
+            case ExifDirectory.TAG_YCBCR_SUBSAMPLING:
+                return getYCbCrSubsamplingDescription();
+            case ExifDirectory.TAG_EXPOSURE_PROGRAM:
+                return getExposureProgramDescription();
+            case ExifDirectory.TAG_APERTURE:
+                return getApertureValueDescription();
+            case ExifDirectory.TAG_MAX_APERTURE:
+                return getMaxApertureValueDescription();
+            case ExifDirectory.TAG_SENSING_METHOD:
+                return getSensingMethodDescription();
+            case ExifDirectory.TAG_EXPOSURE_BIAS:
+                return getExposureBiasDescription();
+            case ExifDirectory.TAG_FILE_SOURCE:
+                return getFileSourceDescription();
+            case ExifDirectory.TAG_SCENE_TYPE:
+                return getSceneTypeDescription();
+            case ExifDirectory.TAG_COMPONENTS_CONFIGURATION:
+                return getComponentConfigurationDescription();
+            case ExifDirectory.TAG_EXIF_VERSION:
+                return getExifVersionDescription();
+            case ExifDirectory.TAG_FLASHPIX_VERSION:
+                return getFlashPixVersionDescription();
+            case ExifDirectory.TAG_REFERENCE_BLACK_WHITE:
+                return getReferenceBlackWhiteDescription();
+            case ExifDirectory.TAG_ISO_EQUIVALENT:
+                return getIsoEquivalentDescription();
+            case ExifDirectory.TAG_THUMBNAIL_DATA:
+                return getThumbnailDescription();
+            case ExifDirectory.TAG_USER_COMMENT:
+                return getUserCommentDescription();
+            case ExifDirectory.TAG_CUSTOM_RENDERED:
+                return getCustomRenderedDescription();
+            case ExifDirectory.TAG_EXPOSURE_MODE:
+                return getExposureModeDescription();
+            case ExifDirectory.TAG_WHITE_BALANCE_MODE:
+                return getWhiteBalanceModeDescription();
+            case ExifDirectory.TAG_DIGITAL_ZOOM_RATIO:
+                return getDigitalZoomRatioDescription();
+            case ExifDirectory.TAG_35MM_FILM_EQUIV_FOCAL_LENGTH:
+                return get35mmFilmEquivFocalLengthDescription();
+            case ExifDirectory.TAG_SCENE_CAPTURE_TYPE:
+                return getSceneCaptureTypeDescription();
+            case ExifDirectory.TAG_GAIN_CONTROL:
+                return getGainControlDescription();
+            case ExifDirectory.TAG_CONTRAST:
+                return getContrastDescription();
+            case ExifDirectory.TAG_SATURATION:
+                return getSaturationDescription();
+            case ExifDirectory.TAG_SHARPNESS:
+                return getSharpnessDescription();
+            case ExifDirectory.TAG_SUBJECT_DISTANCE_RANGE:
+                return getSubjectDistanceRangeDescription();
+
+            case ExifDirectory.TAG_WIN_AUTHOR:
+               return getWindowsAuthorDescription();
+            case ExifDirectory.TAG_WIN_COMMENT:
+               return getWindowsCommentDescription();
+            case ExifDirectory.TAG_WIN_KEYWORDS:
+               return getWindowsKeywordsDescription();
+            case ExifDirectory.TAG_WIN_SUBJECT:
+               return getWindowsSubjectDescription();
+            case ExifDirectory.TAG_WIN_TITLE:
+               return getWindowsTitleDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getNewSubfileTypeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_NEW_SUBFILE_TYPE)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_NEW_SUBFILE_TYPE)) {
+            case 1: return "Full-resolution image";
+            case 2: return "Reduced-resolution image";
+            case 3: return "Single page of multi-page reduced-resolution image";
+            case 4: return "Transparency mask";
+            case 5: return "Transparency mask of reduced-resolution image";
+            case 6: return "Transparency mask of multi-page image";
+            case 7: return "Transparency mask of reduced-resolution multi-page image";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_NEW_SUBFILE_TYPE) + ")";
+        }
+    }
+
+    public String getSubfileTypeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_SUBFILE_TYPE)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_SUBFILE_TYPE)) {
+            case 1: return "Full-resolution image";
+            case 2: return "Reduced-resolution image";
+            case 3: return "Single page of multi-page image";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_SUBFILE_TYPE) + ")";
+        }
+    }
+
+    public String getThresholdingDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_THRESHOLDING)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_THRESHOLDING)) {
+            case 1: return "No dithering or halftoning";
+            case 2: return "Ordered dither or halftone";
+            case 3: return "Randomized dither";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_THRESHOLDING) + ")";
+        }
+    }
+
+    public String getFillOrderDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_FILL_ORDER)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_FILL_ORDER)) {
+            case 1: return "Normal";
+            case 2: return "Reversed";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_FILL_ORDER) + ")";
+        }
+    }
+
+    public String getSubjectDistanceRangeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_SUBJECT_DISTANCE_RANGE)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_SUBJECT_DISTANCE_RANGE)) {
+            case 0:
+                return "Unknown";
+            case 1:
+                return "Macro";
+            case 2:
+                return "Close view";
+            case 3:
+                return "Distant view";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_SUBJECT_DISTANCE_RANGE) + ")";
+        }
+    }
+
+    public String getSharpnessDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_SHARPNESS)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_SHARPNESS)) {
+            case 0:
+                return "None";
+            case 1:
+                return "Low";
+            case 2:
+                return "Hard";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_SHARPNESS) + ")";
+        }
+    }
+
+    public String getSaturationDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_SATURATION)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_SATURATION)) {
+            case 0:
+                return "None";
+            case 1:
+                return "Low saturation";
+            case 2:
+                return "High saturation";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_SATURATION) + ")";
+        }
+    }
+
+    public String getContrastDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_CONTRAST)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_CONTRAST)) {
+            case 0:
+                return "None";
+            case 1:
+                return "Soft";
+            case 2:
+                return "Hard";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_CONTRAST) + ")";
+        }
+    }
+
+    public String getGainControlDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_GAIN_CONTROL)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_GAIN_CONTROL)) {
+            case 0:
+                return "None";
+            case 1:
+                return "Low gain up";
+            case 2:
+                return "Low gain down";
+            case 3:
+                return "High gain up";
+            case 4:
+                return "High gain down";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_GAIN_CONTROL) + ")";
+        }
+    }
+
+    public String getSceneCaptureTypeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_SCENE_CAPTURE_TYPE)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_SCENE_CAPTURE_TYPE)) {
+            case 0:
+                return "Standard";
+            case 1:
+                return "Landscape";
+            case 2:
+                return "Portrait";
+            case 3:
+                return "Night scene";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_SCENE_CAPTURE_TYPE) + ")";
+        }
+    }
+
+    public String get35mmFilmEquivFocalLengthDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_35MM_FILM_EQUIV_FOCAL_LENGTH)) return null;
+        int equivalentFocalLength = _directory.getInt(ExifDirectory.TAG_35MM_FILM_EQUIV_FOCAL_LENGTH);
+
+        if (equivalentFocalLength==0)
+            return "Unknown";
+        else
+            return SimpleDecimalFormatter.format(equivalentFocalLength) + "mm";
+    }
+
+    public String getDigitalZoomRatioDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_DIGITAL_ZOOM_RATIO)) return null;
+        Rational rational = _directory.getRational(ExifDirectory.TAG_DIGITAL_ZOOM_RATIO);
+        if (rational.getNumerator()==0)
+            return "Digital zoom not used.";
+
+        return SimpleDecimalFormatter.format(rational.doubleValue());
+    }
+
+    public String getWhiteBalanceModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_WHITE_BALANCE_MODE)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_WHITE_BALANCE_MODE)) {
+            case 0:
+                return "Auto white balance";
+            case 1:
+                return "Manual white balance";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_WHITE_BALANCE_MODE) + ")";
+        }
+    }
+
+    public String getExposureModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_EXPOSURE_MODE)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_EXPOSURE_MODE)) {
+            case 0:
+                return "Auto exposure";
+            case 1:
+                return "Manual exposure";
+            case 2:
+                return "Auto bracket";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_EXPOSURE_MODE) + ")";
+        }
+    }
+
+    public String getCustomRenderedDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_CUSTOM_RENDERED)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_CUSTOM_RENDERED)) {
+            case 0:
+                return "Normal process";
+            case 1:
+                return "Custom process";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_CUSTOM_RENDERED) + ")";
+        }
+    }
+
+    public String getUserCommentDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_USER_COMMENT)) return null;
+
+        byte[] commentBytes = _directory.getByteArray(ExifDirectory.TAG_USER_COMMENT);
+
+        if (commentBytes.length==0)
+            return "";
+
+        final String[] encodingNames = new String[] { "ASCII", "UNICODE", "JIS" };
+
+        if (commentBytes.length>=10)
+        {
+            String encodingRegion = new String(commentBytes, 0, 10);
+
+            // try each encoding name
+            for (int i = 0; i<encodingNames.length; i++) {
+                String encodingName = encodingNames[i];
+                if (encodingRegion.startsWith(encodingName))
+                {
+                    // remove the null characters (and any spaces) commonly present after the encoding name
+                    for (int j = encodingName.length(); j<10; j++) {
+                        byte b = commentBytes[j];
+                        if (b!='\0' && b!=' ') {
+                           if (encodingName.equals("UNICODE")) {
+                              try {
+                                 return new String(commentBytes, j, commentBytes.length - j, "UTF-16LE").trim();
+                              }
+                              catch (UnsupportedEncodingException ex) {
+                                 return null;
+                              }
+                           }
+                           return new String(commentBytes, j, commentBytes.length - j).trim();
+                        }
+                    }
+                    return new String(commentBytes, 10, commentBytes.length - 10).trim();
+                }
+            }
+        }
+
+        // special handling fell through, return a plain string representation
+        return new String(commentBytes).trim();
+    }
+
+    public String getThumbnailDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_THUMBNAIL_DATA)) return null;
+        int[] thumbnailBytes = _directory.getIntArray(ExifDirectory.TAG_THUMBNAIL_DATA);
+        return "[" + thumbnailBytes.length + " bytes of thumbnail data]";
+    }
+
+    public String getIsoEquivalentDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_ISO_EQUIVALENT)) return null;
+        int isoEquiv = _directory.getInt(ExifDirectory.TAG_ISO_EQUIVALENT);
+        if (isoEquiv < 50) {
+            isoEquiv *= 200;
+        }
+        return Integer.toString(isoEquiv);
+    }
+
+    public String getReferenceBlackWhiteDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_REFERENCE_BLACK_WHITE)) return null;
+        int[] ints = _directory.getIntArray(ExifDirectory.TAG_REFERENCE_BLACK_WHITE);
+        int blackR = ints[0];
+        int whiteR = ints[1];
+        int blackG = ints[2];
+        int whiteG = ints[3];
+        int blackB = ints[4];
+        int whiteB = ints[5];
+        String pos = "[" + blackR + "," + blackG + "," + blackB + "] " +
+                "[" + whiteR + "," + whiteG + "," + whiteB + "]";
+        return pos;
+    }
+
+    public String getExifVersionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_EXIF_VERSION)) return null;
+        int[] ints = _directory.getIntArray(ExifDirectory.TAG_EXIF_VERSION);
+        return ExifDescriptor.convertBytesToVersionString(ints);
+    }
+
+    public String getFlashPixVersionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_FLASHPIX_VERSION)) return null;
+        int[] ints = _directory.getIntArray(ExifDirectory.TAG_FLASHPIX_VERSION);
+        return ExifDescriptor.convertBytesToVersionString(ints);
+    }
+
+    public String getSceneTypeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_SCENE_TYPE)) return null;
+        int sceneType = _directory.getInt(ExifDirectory.TAG_SCENE_TYPE);
+        if (sceneType == 1) {
+            return "Directly photographed image";
+        } else {
+            return "Unknown (" + sceneType + ")";
+        }
+    }
+
+    public String getFileSourceDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_FILE_SOURCE)) return null;
+        int fileSource = _directory.getInt(ExifDirectory.TAG_FILE_SOURCE);
+        if (fileSource == 3) {
+            return "Digital Still Camera (DSC)";
+        } else {
+            return "Unknown (" + fileSource + ")";
+        }
+    }
+
+    public String getExposureBiasDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_EXPOSURE_BIAS)) return null;
+        Rational exposureBias = _directory.getRational(ExifDirectory.TAG_EXPOSURE_BIAS);
+        return exposureBias.toSimpleString(true) + " EV";
+    }
+
+    public String getMaxApertureValueDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_MAX_APERTURE)) return null;
+        double aperture = _directory.getDouble(ExifDirectory.TAG_MAX_APERTURE);
+        double fStop = PhotographicConversions.apertureToFStop(aperture);
+        return "F" + SimpleDecimalFormatter.format(fStop);
+    }
+
+    public String getApertureValueDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_APERTURE)) return null;
+        double aperture = _directory.getDouble(ExifDirectory.TAG_APERTURE);
+        double fStop = PhotographicConversions.apertureToFStop(aperture);
+        return "F" + SimpleDecimalFormatter.format(fStop);
+    }
+
+    public String getExposureProgramDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_EXPOSURE_PROGRAM)) return null;
+        // '1' means manual control, '2' program normal, '3' aperture priority,
+        // '4' shutter priority, '5' program creative (slow program),
+        // '6' program action(high-speed program), '7' portrait mode, '8' landscape mode.
+        switch (_directory.getInt(ExifDirectory.TAG_EXPOSURE_PROGRAM)) {
+            case 1: return "Manual control";
+            case 2: return "Program normal";
+            case 3: return "Aperture priority";
+            case 4: return "Shutter priority";
+            case 5: return "Program creative (slow program)";
+            case 6: return "Program action (high-speed program)";
+            case 7: return "Portrait mode";
+            case 8: return "Landscape mode";
+            default:
+                return "Unknown program (" + _directory.getInt(ExifDirectory.TAG_EXPOSURE_PROGRAM) + ")";
+        }
+    }
+
+    public String getYCbCrSubsamplingDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_YCBCR_SUBSAMPLING)) return null;
+        int[] positions = _directory.getIntArray(ExifDirectory.TAG_YCBCR_SUBSAMPLING);
+        if (positions[0] == 2 && positions[1] == 1) {
+            return "YCbCr4:2:2";
+        } else if (positions[0] == 2 && positions[1] == 2) {
+            return "YCbCr4:2:0";
+        } else {
+            return "(Unknown)";
+        }
+    }
+
+    public String getPlanarConfigurationDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_PLANAR_CONFIGURATION)) return null;
+        // When image format is no compression YCbCr, this value shows byte aligns of YCbCr
+        // data. If value is '1', Y/Cb/Cr value is chunky format, contiguous for each subsampling
+        // pixel. If value is '2', Y/Cb/Cr value is separated and stored to Y plane/Cb plane/Cr
+        // plane format.
+
+        switch (_directory.getInt(ExifDirectory.TAG_PLANAR_CONFIGURATION)) {
+            case 1: return "Chunky (contiguous for each subsampling pixel)";
+            case 2: return "Separate (Y-plane/Cb-plane/Cr-plane format)";
+            default:
+                return "Unknown configuration";
+        }
+    }
+
+    public String getSamplesPerPixelDescription()
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_SAMPLES_PER_PIXEL)) return null;
+        return _directory.getString(ExifDirectory.TAG_SAMPLES_PER_PIXEL) + " samples/pixel";
+    }
+
+    public String getRowsPerStripDescription()
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_ROWS_PER_STRIP)) return null;
+        return _directory.getString(ExifDirectory.TAG_ROWS_PER_STRIP) + " rows/strip";
+    }
+
+    public String getStripByteCountsDescription()
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_STRIP_BYTE_COUNTS)) return null;
+        return _directory.getString(ExifDirectory.TAG_STRIP_BYTE_COUNTS) + " bytes";
+    }
+
+    public String getPhotometricInterpretationDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_PHOTOMETRIC_INTERPRETATION)) return null;
+        // Shows the color space of the image data components
+        switch (_directory.getInt(ExifDirectory.TAG_PHOTOMETRIC_INTERPRETATION)) {
+            case 0: return "WhiteIsZero";
+            case 1: return "BlackIsZero";
+            case 2: return "RGB";
+            case 3: return "RGB Palette";
+            case 4: return "Transparency Mask";
+            case 5: return "CMYK";
+            case 6: return "YCbCr";
+            case 8: return "CIELab";
+            case 9: return "ICCLab";
+            case 10: return "ITULab";
+            case 32803: return "Color Filter Array";
+            case 32844: return "Pixar LogL";
+            case 32845: return "Pixar LogLuv";
+            case 32892: return "Linear Raw";
+            default:
+                return "Unknown colour space";
+        }
+    }
+
+    public String getCompressionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_COMPRESSION)) return null;
+        switch (_directory.getInt(ExifDirectory.TAG_COMPRESSION)) {
+            case 1: return "Uncompressed";
+            case 2: return "CCITT 1D";
+            case 3: return "T4/Group 3 Fax";
+            case 4: return "T6/Group 4 Fax";
+            case 5: return "LZW";
+            case 6: return "JPEG (old-style)";
+            case 7: return "JPEG";
+            case 8: return "Adobe Deflate";
+            case 9: return "JBIG B&W";
+            case 10: return "JBIG Color";
+            case 32766: return "Next";
+            case 32771: return "CCIRLEW";
+            case 32773: return "PackBits";
+            case 32809: return "Thunderscan";
+            case 32895: return "IT8CTPAD";
+            case 32896: return "IT8LW";
+            case 32897: return "IT8MP";
+            case 32898: return "IT8BL";
+            case 32908: return "PixarFilm";
+            case 32909: return "PixarLog";
+            case 32946: return "Deflate";
+            case 32947: return "DCS";
+            case 32661: return "JBIG";
+            case 32676: return "SGILog";
+            case 32677: return "SGILog24";
+            case 32712: return "JPEG 2000";
+            case 32713: return "Nikon NEF Compressed";
+            default:
+                return "Unknown compression";
+        }
+    }
+
+    public String getBitsPerSampleDescription()
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_BITS_PER_SAMPLE)) return null;
+        return _directory.getString(ExifDirectory.TAG_BITS_PER_SAMPLE) + " bits/component/pixel";
+    }
+
+    public String getThumbnailImageWidthDescription()
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_THUMBNAIL_IMAGE_WIDTH)) return null;
+        return _directory.getString(ExifDirectory.TAG_THUMBNAIL_IMAGE_WIDTH) + " pixels";
+    }
+
+    public String getThumbnailImageHeightDescription()
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_THUMBNAIL_IMAGE_HEIGHT)) return null;
+        return _directory.getString(ExifDirectory.TAG_THUMBNAIL_IMAGE_HEIGHT) + " pixels";
+    }
+
+    public String getFocalPlaneXResolutionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_FOCAL_PLANE_X_RES)) return null;
+        Rational rational = _directory.getRational(ExifDirectory.TAG_FOCAL_PLANE_X_RES);
+        return rational.getReciprocal().toSimpleString(_allowDecimalRepresentationOfRationals) + " " +
+                getFocalPlaneResolutionUnitDescription().toLowerCase();
+    }
+
+    public String getFocalPlaneYResolutionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_FOCAL_PLANE_Y_RES)) return null;
+        Rational rational = _directory.getRational(ExifDirectory.TAG_FOCAL_PLANE_Y_RES);
+        return rational.getReciprocal().toSimpleString(_allowDecimalRepresentationOfRationals) + " " +
+                getFocalPlaneResolutionUnitDescription().toLowerCase();
+    }
+
+    public String getFocalPlaneResolutionUnitDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_FOCAL_PLANE_UNIT)) return null;
+        // Unit of FocalPlaneXResoluton/FocalPlaneYResolution. '1' means no-unit,
+        // '2' inch, '3' centimeter.
+        switch (_directory.getInt(ExifDirectory.TAG_FOCAL_PLANE_UNIT)) {
+            case 1:
+                return "(No unit)";
+            case 2:
+                return "Inches";
+            case 3:
+                return "cm";
+            default:
+                return "";
+        }
+    }
+
+    public String getExifImageWidthDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_EXIF_IMAGE_WIDTH)) return null;
+        return _directory.getInt(ExifDirectory.TAG_EXIF_IMAGE_WIDTH) + " pixels";
+    }
+
+    public String getExifImageHeightDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_EXIF_IMAGE_HEIGHT)) return null;
+        return _directory.getInt(ExifDirectory.TAG_EXIF_IMAGE_HEIGHT) + " pixels";
+    }
+
+    public String getColorSpaceDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_COLOR_SPACE)) return null;
+        int colorSpace = _directory.getInt(ExifDirectory.TAG_COLOR_SPACE);
+        if (colorSpace == 1) {
+            return "sRGB";
+        } else if (colorSpace == 65535) {
+            return "Undefined";
+        } else {
+            return "Unknown";
+        }
+    }
+
+    public String getFocalLengthDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_FOCAL_LENGTH)) return null;
+        java.text.DecimalFormat formatter = new DecimalFormat("0.0##");
+        Rational focalLength = _directory.getRational(ExifDirectory.TAG_FOCAL_LENGTH);
+        return formatter.format(focalLength.doubleValue()) + " mm";
+    }
+
+    public String getFlashDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_FLASH)) return null;
+
+        /*
+         * This is a bitmask.
+         * 0 = flash fired
+         * 1 = return detected
+         * 2 = return able to be detected
+         * 3 = unknown
+         * 4 = auto used
+         * 5 = unknown
+         * 6 = red eye reduction used
+         */
+
+        int val = _directory.getInt(ExifDirectory.TAG_FLASH);
+
+        StringBuffer sb = new StringBuffer();
+
+        if ((val & 0x1)!=0)
+            sb.append("Flash fired");
+        else
+            sb.append("Flash did not fire");
+
+        // check if we're able to detect a return, before we mention it
+        if ((val & 0x4)!=0)
+        {
+            if ((val & 0x2)!=0)
+                sb.append(", return detected");
+            else
+                sb.append(", return not detected");
+        }
+
+        if ((val & 0x10)!=0)
+            sb.append(", auto");
+
+        if ((val & 0x40)!=0)
+            sb.append(", red-eye reduction");
+
+        return sb.toString();
+    }
+
+    public String getWhiteBalanceDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_WHITE_BALANCE)) return null;
+        // '0' means unknown, '1' daylight, '2' fluorescent, '3' tungsten, '10' flash,
+        // '17' standard light A, '18' standard light B, '19' standard light C, '20' D55,
+        // '21' D65, '22' D75, '255' other.
+        switch (_directory.getInt(ExifDirectory.TAG_WHITE_BALANCE)) {
+            case 0:
+                return "Unknown";
+            case 1:
+                return "Daylight";
+            case 2:
+                return "Flourescent";
+            case 3:
+                return "Tungsten";
+            case 10:
+                return "Flash";
+            case 17:
+                return "Standard light";
+            case 18:
+                return "Standard light (B)";
+            case 19:
+                return "Standard light (C)";
+            case 20:
+                return "D55";
+            case 21:
+                return "D65";
+            case 22:
+                return "D75";
+            case 255:
+                return "(Other)";
+            default:
+                return "Unknown (" + _directory.getInt(ExifDirectory.TAG_WHITE_BALANCE) + ")";
+        }
+    }
+
+    public String getMeteringModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_METERING_MODE)) return null;
+        // '0' means unknown, '1' average, '2' center weighted average, '3' spot
+        // '4' multi-spot, '5' multi-segment, '6' partial, '255' other
+        int meteringMode = _directory.getInt(ExifDirectory.TAG_METERING_MODE);
+        switch (meteringMode) {
+            case 0:
+                return "Unknown";
+            case 1:
+                return "Average";
+            case 2:
+                return "Center weighted average";
+            case 3:
+                return "Spot";
+            case 4:
+                return "Multi-spot";
+            case 5:
+                return "Multi-segment";
+            case 6:
+                return "Partial";
+            case 255:
+                return "(Other)";
+            default:
+                return "";
+        }
+    }
+
+    public String getSubjectDistanceDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_SUBJECT_DISTANCE)) return null;
+        Rational distance = _directory.getRational(ExifDirectory.TAG_SUBJECT_DISTANCE);
+        java.text.DecimalFormat formatter = new DecimalFormat("0.0##");
+        return formatter.format(distance.doubleValue()) + " metres";
+    }
+
+    public String getCompressionLevelDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_COMPRESSION_LEVEL)) return null;
+        Rational compressionRatio = _directory.getRational(ExifDirectory.TAG_COMPRESSION_LEVEL);
+        String ratio = compressionRatio.toSimpleString(_allowDecimalRepresentationOfRationals);
+        if (compressionRatio.isInteger() && compressionRatio.intValue() == 1) {
+            return ratio + " bit/pixel";
+        } else {
+            return ratio + " bits/pixel";
+        }
+    }
+
+    public String getThumbnailLengthDescription()
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_THUMBNAIL_LENGTH)) return null;
+        return _directory.getString(ExifDirectory.TAG_THUMBNAIL_LENGTH) + " bytes";
+    }
+
+    public String getThumbnailOffsetDescription()
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_THUMBNAIL_OFFSET)) return null;
+        return _directory.getString(ExifDirectory.TAG_THUMBNAIL_OFFSET) + " bytes";
+    }
+
+    public String getYResolutionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_Y_RESOLUTION)) return null;
+        Rational resolution = _directory.getRational(ExifDirectory.TAG_Y_RESOLUTION);
+        return resolution.toSimpleString(_allowDecimalRepresentationOfRationals) +
+                " dots per " +
+                getResolutionDescription().toLowerCase();
+    }
+
+    public String getXResolutionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_X_RESOLUTION)) return null;
+        Rational resolution = _directory.getRational(ExifDirectory.TAG_X_RESOLUTION);
+        return resolution.toSimpleString(_allowDecimalRepresentationOfRationals) +
+                " dots per " +
+                getResolutionDescription().toLowerCase();
+    }
+
+    public String getExposureTimeDescription()
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_EXPOSURE_TIME)) return null;
+        return _directory.getString(ExifDirectory.TAG_EXPOSURE_TIME) + " sec";
+    }
+
+    public String getShutterSpeedDescription() throws MetadataException
+    {
+        // I believe this method to now be stable, but am leaving some alternative snippets of
+        // code in here, to assist anyone who's looking into this (given that I don't have a public CVS).
+
+        if (!_directory.containsTag(ExifDirectory.TAG_SHUTTER_SPEED)) return null;
+//        float apexValue = _directory.getFloat(ExifDirectory.TAG_SHUTTER_SPEED);
+//        int apexPower = (int)Math.pow(2.0, apexValue);
+//        return "1/" + apexPower + " sec";
+        // TODO test this method
+        // thanks to Mark Edwards for spotting and patching a bug in the calculation of this
+        // description (spotted bug using a Canon EOS 300D)
+        // thanks also to Gli Blr for spotting this bug
+        float apexValue = _directory.getFloat(ExifDirectory.TAG_SHUTTER_SPEED);
+        if (apexValue<=1) {
+            float apexPower = (float)(1/(Math.exp(apexValue*Math.log(2))));
+            long apexPower10 = Math.round((double)apexPower * 10.0);
+            float fApexPower = (float) apexPower10 / 10.0f;
+            return fApexPower + " sec";
+        } else {
+            int apexPower = (int)((Math.exp(apexValue*Math.log(2))));
+            return "1/" + apexPower + " sec";
+        }
+
+/*
+        // This alternative implementation offered by Bill Richards
+        // TODO determine which is the correct / more-correct implementation
+        double apexValue = _directory.getDouble(ExifDirectory.TAG_SHUTTER_SPEED);
+        double apexPower = Math.pow(2.0, apexValue);
+
+        StringBuffer sb = new StringBuffer();
+        if (apexPower > 1)
+            apexPower = Math.floor(apexPower);
+
+        if (apexPower < 1) {
+            sb.append((int)Math.round(1/apexPower));
+        } else {
+            sb.append("1/");
+            sb.append((int)apexPower);
+        }
+        sb.append(" sec");
+        return sb.toString();
+*/
+
+    }
+
+    public String getFNumberDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_FNUMBER)) return null;
+        Rational fNumber = _directory.getRational(ExifDirectory.TAG_FNUMBER);
+        return "F" + SimpleDecimalFormatter.format(fNumber.doubleValue());
+    }
+
+    public String getYCbCrPositioningDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_YCBCR_POSITIONING)) return null;
+        int yCbCrPosition = _directory.getInt(ExifDirectory.TAG_YCBCR_POSITIONING);
+        switch (yCbCrPosition) {
+            case 1: return "Center of pixel array";
+            case 2: return "Datum point";
+            default:
+                return String.valueOf(yCbCrPosition);
+        }
+    }
+
+    public String getOrientationDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_ORIENTATION)) return null;
+        int orientation = _directory.getInt(ExifDirectory.TAG_ORIENTATION);
+        switch (orientation) {
+            case 1: return "Top, left side (Horizontal / normal)";
+            case 2: return "Top, right side (Mirror horizontal)";
+            case 3: return "Bottom, right side (Rotate 180)";
+            case 4: return "Bottom, left side (Mirror vertical)";
+            case 5: return "Left side, top (Mirror horizontal and rotate 270 CW)";
+            case 6: return "Right side, top (Rotate 90 CW)";
+            case 7: return "Right side, bottom (Mirror horizontal and rotate 90 CW)";
+            case 8: return "Left side, bottom (Rotate 270 CW)";
+            default:
+                return String.valueOf(orientation);
+        }
+    }
+
+    public String getResolutionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_RESOLUTION_UNIT)) return "";
+        // '1' means no-unit, '2' means inch, '3' means centimeter. Default value is '2'(inch)
+        int resolutionUnit = _directory.getInt(ExifDirectory.TAG_RESOLUTION_UNIT);
+        switch (resolutionUnit) {
+            case 1: return "(No unit)";
+            case 2: return "Inch";
+            case 3: return "cm";
+            default:
+                return "";
+        }
+    }
+
+    public String getSensingMethodDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifDirectory.TAG_SENSING_METHOD)) return null;
+        // '1' Not defined, '2' One-chip color area sensor, '3' Two-chip color area sensor
+        // '4' Three-chip color area sensor, '5' Color sequential area sensor
+        // '7' Trilinear sensor '8' Color sequential linear sensor,  'Other' reserved
+        int sensingMethod = _directory.getInt(ExifDirectory.TAG_SENSING_METHOD);
+        switch (sensingMethod) {
+            case 1:
+                return "(Not defined)";
+            case 2:
+                return "One-chip color area sensor";
+            case 3:
+                return "Two-chip color area sensor";
+            case 4:
+                return "Three-chip color area sensor";
+            case 5:
+                return "Color sequential area sensor";
+            case 7:
+                return "Trilinear sensor";
+            case 8:
+                return "Color sequential linear sensor";
+            default:
+                return "";
+        }
+    }
+
+    public String getComponentConfigurationDescription() throws MetadataException
+    {
+        int[] components = _directory.getIntArray(ExifDirectory.TAG_COMPONENTS_CONFIGURATION);
+        String[] componentStrings = {"", "Y", "Cb", "Cr", "R", "G", "B"};
+        StringBuffer componentConfig = new StringBuffer();
+        for (int i = 0; i < Math.min(4, components.length); i++) {
+            int j = components[i];
+            if (j > 0 && j < componentStrings.length) {
+                componentConfig.append(componentStrings[j]);
+            }
+        }
+        return componentConfig.toString();
+    }
+
+    /**
+     * Takes a series of 4 bytes from the specified offset, and converts these to a
+     * well-known version number, where possible.  For example, (hex) 30 32 31 30 == 2.10).
+     * @param components the four version values
+     * @return the version as a string of form 2.10
+     */
+    public static String convertBytesToVersionString(int[] components)
+    {
+        StringBuffer version = new StringBuffer();
+        for (int i = 0; i < 4 && i < components.length; i++) {
+            if (i == 2) version.append('.');
+            String digit = String.valueOf((char)components[i]);
+            if (i == 0 && "0".equals(digit)) continue;
+            version.append(digit);
+        }
+        return version.toString();
+    }
+
+    /**
+     * The Windows specific tags uses plain Unicode
+     */
+    private String getUnicodeDescription(int tag) throws MetadataException
+    {
+         if (!_directory.containsTag(tag)) return null;
+         byte[] commentBytes = _directory.getByteArray(tag);
+         try {
+             // decode the unicode string
+             // trim it, as i'm seeing a junk character on the end
+            return new String(commentBytes, "UTF-16LE").trim();
+         }
+         catch (UnsupportedEncodingException ex) {
+            return null;
+         }
+    }
+
+    public String getWindowsAuthorDescription() throws MetadataException
+    {
+       return getUnicodeDescription(ExifDirectory.TAG_WIN_AUTHOR);
+    }
+
+    public String getWindowsCommentDescription() throws MetadataException
+    {
+       return getUnicodeDescription(ExifDirectory.TAG_WIN_COMMENT);
+    }
+
+    public String getWindowsKeywordsDescription() throws MetadataException
+    {
+       return getUnicodeDescription(ExifDirectory.TAG_WIN_KEYWORDS);
+    }
+
+    public String getWindowsTitleDescription() throws MetadataException
+    {
+       return getUnicodeDescription(ExifDirectory.TAG_WIN_TITLE);
+    }
+
+    public String getWindowsSubjectDescription() throws MetadataException
+    {
+       return getUnicodeDescription(ExifDirectory.TAG_WIN_SUBJECT);
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/ExifDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/ExifDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/ExifDirectory.java	(revision 4231)
@@ -0,0 +1,954 @@
+/*
+ * ExifDirectory.java
+ *
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 25-Nov-2002 20:41:00 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.HashMap;
+
+/**
+ *
+ */
+public class ExifDirectory extends Directory
+{
+    // TODO do these tags belong in the exif directory?
+    public static final int TAG_SUB_IFDS = 0x014A;
+    public static final int TAG_GPS_INFO = 0x8825;
+
+    /**
+     * The actual aperture value of lens when the image was taken. Unit is APEX.
+     * To convert this value to ordinary F-number (F-stop), calculate this value's
+     * power of root 2 (=1.4142). For example, if the ApertureValue is '5',
+     * F-number is 1.4142^5 = F5.6.
+     */
+    public static final int TAG_APERTURE = 0x9202;
+    /**
+     * When image format is no compression, this value shows the number of bits
+     * per component for each pixel. Usually this value is '8,8,8'.
+     */
+    public static final int TAG_BITS_PER_SAMPLE = 0x0102;
+    /**
+     * Shows compression method for Thumbnail.
+     * 1 = Uncompressed
+     * 2 = CCITT 1D
+     * 3 = T4/Group 3 Fax
+     * 4 = T6/Group 4 Fax
+     * 5 = LZW
+     * 6 = JPEG (old-style)
+     * 7 = JPEG
+     * 8 = Adobe Deflate
+     * 9 = JBIG B&W
+     * 10 = JBIG Color
+     * 32766 = Next
+     * 32771 = CCIRLEW
+     * 32773 = PackBits
+     * 32809 = Thunderscan
+     * 32895 = IT8CTPAD
+     * 32896 = IT8LW
+     * 32897 = IT8MP
+     * 32898 = IT8BL
+     * 32908 = PixarFilm
+     * 32909 = PixarLog
+     * 32946 = Deflate
+     * 32947 = DCS
+     * 34661 = JBIG
+     * 34676 = SGILog
+     * 34677 = SGILog24
+     * 34712 = JPEG 2000
+     * 34713 = Nikon NEF Compressed
+     */
+    public static final int TAG_COMPRESSION = 0x0103;
+    public static final int COMPRESSION_NONE = 1;
+    public static final int COMPRESSION_JPEG = 6;
+
+    /**
+     * Shows the color space of the image data components.
+     * 0 = WhiteIsZero
+     * 1 = BlackIsZero
+     * 2 = RGB
+     * 3 = RGB Palette
+     * 4 = Transparency Mask
+     * 5 = CMYK
+     * 6 = YCbCr
+     * 8 = CIELab
+     * 9 = ICCLab
+     * 10 = ITULab
+     * 32803 = Color Filter Array
+     * 32844 = Pixar LogL
+     * 32845 = Pixar LogLuv
+     * 34892 = Linear Raw
+     */
+    public static final int TAG_PHOTOMETRIC_INTERPRETATION = 0x0106;
+    /**
+     * 1 = No dithering or halftoning
+     * 2 = Ordered dither or halftone
+     * 3 = Randomized dither
+     */
+    public static final int TAG_THRESHOLDING = 0x0107;
+    public static final int PHOTOMETRIC_INTERPRETATION_MONOCHROME = 1;
+    public static final int PHOTOMETRIC_INTERPRETATION_RGB = 2;
+    public static final int PHOTOMETRIC_INTERPRETATION_YCBCR = 6;
+
+    /** The position in the file of raster data. */
+    public static final int TAG_STRIP_OFFSETS = 0x0111;
+    /** Each pixel is composed of this many samples. */
+    public static final int TAG_SAMPLES_PER_PIXEL = 0x0115;
+    /** The raster is codified by a single block of data holding this many rows. */
+    public static final int TAG_ROWS_PER_STRIP = 0x116;
+    /** The size of the raster data in bytes. */
+    public static final int TAG_STRIP_BYTE_COUNTS = 0x0117;
+    public static final int TAG_MIN_SAMPLE_VALUE = 0x0118;
+    public static final int TAG_MAX_SAMPLE_VALUE = 0x0119;
+    /**
+     * When image format is no compression YCbCr, this value shows byte aligns of
+     * YCbCr data. If value is '1', Y/Cb/Cr value is chunky format, contiguous for
+     * each subsampling pixel. If value is '2', Y/Cb/Cr value is separated and
+     * stored to Y plane/Cb plane/Cr plane format.
+     */
+    public static final int TAG_PLANAR_CONFIGURATION = 0x011C;
+    public static final int TAG_YCBCR_SUBSAMPLING = 0x0212;
+    public static final int TAG_IMAGE_DESCRIPTION = 0x010E;
+    public static final int TAG_SOFTWARE = 0x0131;
+    public static final int TAG_DATETIME = 0x0132;
+    public static final int TAG_WHITE_POINT = 0x013E;
+    public static final int TAG_PRIMARY_CHROMATICITIES = 0x013F;
+    public static final int TAG_YCBCR_COEFFICIENTS = 0x0211;
+    public static final int TAG_REFERENCE_BLACK_WHITE = 0x0214;
+    public static final int TAG_COPYRIGHT = 0x8298;
+
+    /**
+     * The new subfile type tag.
+     * 0 = Full-resolution Image
+     * 1 = Reduced-resolution image
+     * 2 = Single page of multi-page image
+     * 3 = Single page of multi-page reduced-resolution image
+     * 4 = Transparency mask
+     * 5 = Transparency mask of reduced-resolution image
+     * 6 = Transparency mask of multi-page image
+     * 7 = Transparency mask of reduced-resolution multi-page image
+     */
+    public static final int TAG_NEW_SUBFILE_TYPE = 0x00FE;
+    /**
+     * The old subfile type tag.
+     * 1 = Full-resolution image (Main image)
+     * 2 = Reduced-resolution image (Thumbnail)
+     * 3 = Single page of multi-page image
+     */
+    public static final int TAG_SUBFILE_TYPE = 0x00FF;
+    public static final int TAG_TRANSFER_FUNCTION = 0x012D;
+    public static final int TAG_ARTIST = 0x013B;
+    public static final int TAG_PREDICTOR = 0x013D;
+    public static final int TAG_TILE_WIDTH = 0x0142;
+    public static final int TAG_TILE_LENGTH = 0x0143;
+    public static final int TAG_TILE_OFFSETS = 0x0144;
+    public static final int TAG_TILE_BYTE_COUNTS = 0x0145;
+    public static final int TAG_JPEG_TABLES = 0x015B;
+    public static final int TAG_CFA_REPEAT_PATTERN_DIM = 0x828D;
+    /** There are two definitions for CFA pattern, I don't know the difference... */
+    public static final int TAG_CFA_PATTERN_2 = 0x828E;
+    public static final int TAG_BATTERY_LEVEL = 0x828F;
+    public static final int TAG_IPTC_NAA = 0x83BB;
+    public static final int TAG_INTER_COLOR_PROFILE = 0x8773;
+    public static final int TAG_SPECTRAL_SENSITIVITY = 0x8824;
+    public static final int TAG_OECF = 0x8828;
+    public static final int TAG_INTERLACE = 0x8829;
+    public static final int TAG_TIME_ZONE_OFFSET = 0x882A;
+    public static final int TAG_SELF_TIMER_MODE = 0x882B;
+    public static final int TAG_FLASH_ENERGY = 0x920B;
+    public static final int TAG_SPATIAL_FREQ_RESPONSE = 0x920C;
+    public static final int TAG_NOISE = 0x920D;
+    public static final int TAG_IMAGE_NUMBER = 0x9211;
+    public static final int TAG_SECURITY_CLASSIFICATION = 0x9212;
+    public static final int TAG_IMAGE_HISTORY = 0x9213;
+    public static final int TAG_SUBJECT_LOCATION = 0x9214;
+    /** There are two definitions for exposure index, I don't know the difference... */
+    public static final int TAG_EXPOSURE_INDEX_2 = 0x9215;
+    public static final int TAG_TIFF_EP_STANDARD_ID = 0x9216;
+    public static final int TAG_FLASH_ENERGY_2 = 0xA20B;
+    public static final int TAG_SPATIAL_FREQ_RESPONSE_2 = 0xA20C;
+    public static final int TAG_SUBJECT_LOCATION_2 = 0xA214;
+    public static final int TAG_MAKE = 0x010F;
+    public static final int TAG_MODEL = 0x0110;
+    public static final int TAG_ORIENTATION = 0x0112;
+    public static final int TAG_X_RESOLUTION = 0x011A;
+    public static final int TAG_Y_RESOLUTION = 0x011B;
+    public static final int TAG_PAGE_NAME = 0x011D;
+    public static final int TAG_RESOLUTION_UNIT = 0x0128;
+    public static final int TAG_THUMBNAIL_OFFSET = 0x0201;
+    public static final int TAG_THUMBNAIL_LENGTH = 0x0202;
+    public static final int TAG_YCBCR_POSITIONING = 0x0213;
+    /**
+     * Exposure time (reciprocal of shutter speed). Unit is second.
+     */
+    public static final int TAG_EXPOSURE_TIME = 0x829A;
+    /**
+     * The actual F-number(F-stop) of lens when the image was taken.
+     */
+    public static final int TAG_FNUMBER = 0x829D;
+    /**
+     * Exposure program that the camera used when image was taken. '1' means
+     * manual control, '2' program normal, '3' aperture priority, '4' shutter
+     * priority, '5' program creative (slow program), '6' program action
+     * (high-speed program), '7' portrait mode, '8' landscape mode.
+     */
+    public static final int TAG_EXPOSURE_PROGRAM = 0x8822;
+    public static final int TAG_ISO_EQUIVALENT = 0x8827;
+    public static final int TAG_EXIF_VERSION = 0x9000;
+    public static final int TAG_DATETIME_ORIGINAL = 0x9003;
+    public static final int TAG_DATETIME_DIGITIZED = 0x9004;
+    public static final int TAG_COMPONENTS_CONFIGURATION = 0x9101;
+    /**
+     * Average (rough estimate) compression level in JPEG bits per pixel.
+     * */
+    public static final int TAG_COMPRESSION_LEVEL = 0x9102;
+    /**
+     * Shutter speed by APEX value. To convert this value to ordinary 'Shutter Speed';
+     * calculate this value's power of 2, then reciprocal. For example, if the
+     * ShutterSpeedValue is '4', shutter speed is 1/(24)=1/16 second.
+     */
+    public static final int TAG_SHUTTER_SPEED = 0x9201;
+    public static final int TAG_BRIGHTNESS_VALUE = 0x9203;
+    public static final int TAG_EXPOSURE_BIAS = 0x9204;
+    /**
+     * Maximum aperture value of lens. You can convert to F-number by calculating
+     * power of root 2 (same process of ApertureValue:0x9202).
+     * The actual aperture value of lens when the image was taken. To convert this
+     * value to ordinary f-number(f-stop), calculate the value's power of root 2
+     * (=1.4142). For example, if the ApertureValue is '5', f-number is 1.41425^5 = F5.6.
+     */
+    public static final int TAG_MAX_APERTURE = 0x9205;
+    /**
+     * Indicates the distance the autofocus camera is focused to.  Tends to be less accurate as distance increases.
+     */
+    public static final int TAG_SUBJECT_DISTANCE = 0x9206;
+    /**
+     * Exposure metering method. '0' means unknown, '1' average, '2' center
+     * weighted average, '3' spot, '4' multi-spot, '5' multi-segment, '6' partial,
+     * '255' other.
+     */
+    public static final int TAG_METERING_MODE = 0x9207;
+
+    public static final int TAG_LIGHT_SOURCE = 0x9208;
+    /**
+     * White balance (aka light source). '0' means unknown, '1' daylight,
+     * '2' fluorescent, '3' tungsten, '10' flash, '17' standard light A,
+     * '18' standard light B, '19' standard light C, '20' D55, '21' D65,
+     * '22' D75, '255' other.
+     */
+    public static final int TAG_WHITE_BALANCE = 0x9208;
+    /**
+     * 0x0  = 0000000 = No Flash
+     * 0x1  = 0000001 = Fired
+     * 0x5  = 0000101 = Fired, Return not detected
+     * 0x7  = 0000111 = Fired, Return detected
+     * 0x9  = 0001001 = On
+     * 0xd  = 0001101 = On, Return not detected
+     * 0xf  = 0001111 = On, Return detected
+     * 0x10 = 0010000 = Off
+     * 0x18 = 0011000 = Auto, Did not fire
+     * 0x19 = 0011001 = Auto, Fired
+     * 0x1d = 0011101 = Auto, Fired, Return not detected
+     * 0x1f = 0011111 = Auto, Fired, Return detected
+     * 0x20 = 0100000 = No flash function
+     * 0x41 = 1000001 = Fired, Red-eye reduction
+     * 0x45 = 1000101 = Fired, Red-eye reduction, Return not detected
+     * 0x47 = 1000111 = Fired, Red-eye reduction, Return detected
+     * 0x49 = 1001001 = On, Red-eye reduction
+     * 0x4d = 1001101 = On, Red-eye reduction, Return not detected
+     * 0x4f = 1001111 = On, Red-eye reduction, Return detected
+     * 0x59 = 1011001 = Auto, Fired, Red-eye reduction
+     * 0x5d = 1011101 = Auto, Fired, Red-eye reduction, Return not detected
+     * 0x5f = 1011111 = Auto, Fired, Red-eye reduction, Return detected
+     *        6543210 (positions)
+     *
+     * This is a bitmask.
+     * 0 = flash fired
+     * 1 = return detected
+     * 2 = return able to be detected
+     * 3 = unknown
+     * 4 = auto used
+     * 5 = unknown
+     * 6 = red eye reduction used
+     */
+    public static final int TAG_FLASH = 0x9209;
+    /**
+     * Focal length of lens used to take image.  Unit is millimeter.
+     * Nice digital cameras actually save the focal length as a function of how far they are zoomed in.
+     */
+    public static final int TAG_FOCAL_LENGTH = 0x920A;
+    public static final int TAG_USER_COMMENT = 0x9286;
+    public static final int TAG_SUBSECOND_TIME = 0x9290;
+    public static final int TAG_SUBSECOND_TIME_ORIGINAL = 0x9291;
+    public static final int TAG_SUBSECOND_TIME_DIGITIZED = 0x9292;
+    public static final int TAG_FLASHPIX_VERSION = 0xA000;
+    /**
+     * Defines Color Space. DCF image must use sRGB color space so value is
+     * always '1'. If the picture uses the other color space, value is
+     * '65535':Uncalibrated.
+     */
+    public static final int TAG_COLOR_SPACE = 0xA001;
+    public static final int TAG_EXIF_IMAGE_WIDTH = 0xA002;
+    public static final int TAG_EXIF_IMAGE_HEIGHT = 0xA003;
+    public static final int TAG_RELATED_SOUND_FILE = 0xA004;
+    public static final int TAG_FOCAL_PLANE_X_RES = 0xA20E;
+    public static final int TAG_FOCAL_PLANE_Y_RES = 0xA20F;
+    /**
+     * Unit of FocalPlaneXResoluton/FocalPlaneYResolution. '1' means no-unit,
+     * '2' inch, '3' centimeter.
+     *
+     * Note: Some of Fujifilm's digicam(e.g.FX2700,FX2900,Finepix4700Z/40i etc)
+     * uses value '3' so it must be 'centimeter', but it seems that they use a
+     * '8.3mm?'(1/3in.?) to their ResolutionUnit. Fuji's BUG? Finepix4900Z has
+     * been changed to use value '2' but it doesn't match to actual value also.
+     */
+    public static final int TAG_FOCAL_PLANE_UNIT = 0xA210;
+    public static final int TAG_EXPOSURE_INDEX = 0xA215;
+    public static final int TAG_SENSING_METHOD = 0xA217;
+    public static final int TAG_FILE_SOURCE = 0xA300;
+    public static final int TAG_SCENE_TYPE = 0xA301;
+    public static final int TAG_CFA_PATTERN = 0xA302;
+
+    // these tags new with Exif 2.2 (?) [A401 - A4
+    /**
+     * This tag indicates the use of special processing on image data, such as rendering
+     * geared to output. When special processing is performed, the reader is expected to
+     * disable or minimize any further processing.
+     * Tag = 41985 (A401.H)
+     * Type = SHORT
+     * Count = 1
+     * Default = 0
+     *   0 = Normal process
+     *   1 = Custom process
+     *   Other = reserved
+     */
+    public static final int TAG_CUSTOM_RENDERED = 0xA401;
+
+    /**
+     * This tag indicates the exposure mode set when the image was shot. In auto-bracketing
+     * mode, the camera shoots a series of frames of the same scene at different exposure settings.
+     * Tag = 41986 (A402.H)
+     * Type = SHORT
+     * Count = 1
+     * Default = none
+     *   0 = Auto exposure
+     *   1 = Manual exposure
+     *   2 = Auto bracket
+     *   Other = reserved
+     */
+    public static final int TAG_EXPOSURE_MODE = 0xA402;
+
+    /**
+     * This tag indicates the white balance mode set when the image was shot.
+     * Tag = 41987 (A403.H)
+     * Type = SHORT
+     * Count = 1
+     * Default = none
+     *   0 = Auto white balance
+     *   1 = Manual white balance
+     *   Other = reserved
+     */
+    public static final int TAG_WHITE_BALANCE_MODE = 0xA403;
+
+    /**
+     * This tag indicates the digital zoom ratio when the image was shot. If the
+     * numerator of the recorded value is 0, this indicates that digital zoom was
+     * not used.
+     * Tag = 41988 (A404.H)
+     * Type = RATIONAL
+     * Count = 1
+     * Default = none
+     */
+    public static final int TAG_DIGITAL_ZOOM_RATIO = 0xA404;
+
+    /**
+     * This tag indicates the equivalent focal length assuming a 35mm film camera,
+     * in mm. A value of 0 means the focal length is unknown. Note that this tag
+     * differs from the FocalLength tag.
+     * Tag = 41989 (A405.H)
+     * Type = SHORT
+     * Count = 1
+     * Default = none
+     */
+    public static final int TAG_35MM_FILM_EQUIV_FOCAL_LENGTH = 0xA405;
+
+    /**
+     * This tag indicates the type of scene that was shot. It can also be used to
+     * record the mode in which the image was shot. Note that this differs from
+     * the scene type (SceneType) tag.
+     * Tag = 41990 (A406.H)
+     * Type = SHORT
+     * Count = 1
+     * Default = 0
+     *   0 = Standard
+     *   1 = Landscape
+     *   2 = Portrait
+     *   3 = Night scene
+     *   Other = reserved
+     */
+    public static final int TAG_SCENE_CAPTURE_TYPE = 0xA406;
+
+    /**
+     * This tag indicates the degree of overall image gain adjustment.
+     * Tag = 41991 (A407.H)
+     * Type = SHORT
+     * Count = 1
+     * Default = none
+     *   0 = None
+     *   1 = Low gain up
+     *   2 = High gain up
+     *   3 = Low gain down
+     *   4 = High gain down
+     *   Other = reserved
+     */
+    public static final int TAG_GAIN_CONTROL = 0xA407;
+
+    /**
+     * This tag indicates the direction of contrast processing applied by the camera
+     * when the image was shot.
+     * Tag = 41992 (A408.H)
+     * Type = SHORT
+     * Count = 1
+     * Default = 0
+     *   0 = Normal
+     *   1 = Soft
+     *   2 = Hard
+     *   Other = reserved
+     */
+    public static final int TAG_CONTRAST = 0xA408;
+
+    /**
+     * This tag indicates the direction of saturation processing applied by the camera
+     * when the image was shot.
+     * Tag = 41993 (A409.H)
+     * Type = SHORT
+     * Count = 1
+     * Default = 0
+     *   0 = Normal
+     *   1 = Low saturation
+     *   2 = High saturation
+     *   Other = reserved
+     */
+    public static final int TAG_SATURATION = 0xA409;
+
+    /**
+     * This tag indicates the direction of sharpness processing applied by the camera
+     * when the image was shot.
+     * Tag = 41994 (A40A.H)
+     * Type = SHORT
+     * Count = 1
+     * Default = 0
+     *   0 = Normal
+     *   1 = Soft
+     *   2 = Hard
+     *   Other = reserved
+     */
+    public static final int TAG_SHARPNESS = 0xA40A;
+
+    // TODO support this tag (I haven't seen a camera's actual implementation of this yet)
+
+    /**
+     * This tag indicates information on the picture-taking conditions of a particular
+     * camera model. The tag is used only to indicate the picture-taking conditions in
+     * the reader.
+     * Tag = 41995 (A40B.H)
+     * Type = UNDEFINED
+     * Count = Any
+     * Default = none
+     *
+     * The information is recorded in the format shown below. The data is recorded
+     * in Unicode using SHORT type for the number of display rows and columns and
+     * UNDEFINED type for the camera settings. The Unicode (UCS-2) string including
+     * Signature is NULL terminated. The specifics of the Unicode string are as given
+     * in ISO/IEC 10464-1.
+     *
+     *      Length  Type        Meaning
+     *      ------+-----------+------------------
+     *      2       SHORT       Display columns
+     *      2       SHORT       Display rows
+     *      Any     UNDEFINED   Camera setting-1
+     *      Any     UNDEFINED   Camera setting-2
+     *      :       :           :
+     *      Any     UNDEFINED   Camera setting-n
+     */
+    public static final int TAG_DEVICE_SETTING_DESCRIPTION = 0xA40B;
+
+    /**
+     * This tag indicates the distance to the subject.
+     * Tag = 41996 (A40C.H)
+     * Type = SHORT
+     * Count = 1
+     * Default = none
+     *   0 = unknown
+     *   1 = Macro
+     *   2 = Close view
+     *   3 = Distant view
+     *   Other = reserved
+     */
+    public static final int TAG_SUBJECT_DISTANCE_RANGE = 0xA40C;
+
+    /**
+     * The image title, as used by Windows XP.
+     */
+    public static final int TAG_WIN_TITLE = 0x9C9B;
+
+    /**
+     * The image comment, as used by Windows XP.
+     */
+    public static final int TAG_WIN_COMMENT = 0x9C9C;
+
+    /**
+     * The image author, as used by Windows XP (called Artist in the Windows shell).
+     */
+    public static final int TAG_WIN_AUTHOR = 0x9C9D;
+
+    /**
+     * The image keywords, as used by Windows XP.
+     */
+    public static final int TAG_WIN_KEYWORDS = 0x9C9E;
+
+    /**
+     * The image subject, as used by Windows XP.
+     */
+    public static final int TAG_WIN_SUBJECT = 0x9C9F;
+
+    /**
+     * This tag indicates an identifier assigned uniquely to each image. It is
+     * recorded as an ASCII string equivalent to hexadecimal notation and 128-bit
+     * fixed length.
+     * Tag = 42016 (A420.H)
+     * Type = ASCII
+     * Count = 33
+     * Default = none
+     */
+    public static final int TAG_IMAGE_UNIQUE_ID = 0xA420;
+
+    public static final int TAG_THUMBNAIL_IMAGE_WIDTH = 0x0100;
+    public static final int TAG_THUMBNAIL_IMAGE_HEIGHT = 0x0101;
+    public static final int TAG_THUMBNAIL_DATA = 0xF001;
+
+    /**
+     * 1 = Normal
+     * 2 = Reversed
+     */
+    public static final int TAG_FILL_ORDER = 0x010A;
+    public static final int TAG_DOCUMENT_NAME = 0x010D;
+
+    protected static final HashMap tagNameMap = new HashMap();
+
+    static
+    {
+        tagNameMap.put(new Integer(TAG_FILL_ORDER), "Fill Order");
+        tagNameMap.put(new Integer(TAG_DOCUMENT_NAME), "Document Name");
+        tagNameMap.put(new Integer(0x1000), "Related Image File Format");
+        tagNameMap.put(new Integer(0x1001), "Related Image Width");
+        tagNameMap.put(new Integer(0x1002), "Related Image Length");
+        tagNameMap.put(new Integer(0x0156), "Transfer Range");
+        tagNameMap.put(new Integer(0x0200), "JPEG Proc");
+        tagNameMap.put(new Integer(0x8769), "Exif Offset");
+        tagNameMap.put(new Integer(TAG_COMPRESSION_LEVEL), "Compressed Bits Per Pixel");
+        tagNameMap.put(new Integer(0x927C), "Maker Note");
+        tagNameMap.put(new Integer(0xA005), "Interoperability Offset");
+
+        tagNameMap.put(new Integer(TAG_NEW_SUBFILE_TYPE), "New Subfile Type");
+        tagNameMap.put(new Integer(TAG_SUBFILE_TYPE), "Subfile Type");
+        tagNameMap.put(new Integer(TAG_THUMBNAIL_IMAGE_WIDTH), "Thumbnail Image Width");
+        tagNameMap.put(new Integer(TAG_THUMBNAIL_IMAGE_HEIGHT), "Thumbnail Image Height");
+        tagNameMap.put(new Integer(TAG_BITS_PER_SAMPLE), "Bits Per Sample");
+        tagNameMap.put(new Integer(TAG_COMPRESSION), "Compression");
+        tagNameMap.put(new Integer(TAG_PHOTOMETRIC_INTERPRETATION), "Photometric Interpretation");
+        tagNameMap.put(new Integer(TAG_THRESHOLDING), "Thresholding");
+        tagNameMap.put(new Integer(TAG_IMAGE_DESCRIPTION), "Image Description");
+        tagNameMap.put(new Integer(TAG_MAKE), "Make");
+        tagNameMap.put(new Integer(TAG_MODEL), "Model");
+        tagNameMap.put(new Integer(TAG_STRIP_OFFSETS), "Strip Offsets");
+        tagNameMap.put(new Integer(TAG_ORIENTATION), "Orientation");
+        tagNameMap.put(new Integer(TAG_SAMPLES_PER_PIXEL), "Samples Per Pixel");
+        tagNameMap.put(new Integer(TAG_ROWS_PER_STRIP), "Rows Per Strip");
+        tagNameMap.put(new Integer(TAG_STRIP_BYTE_COUNTS), "Strip Byte Counts");
+        tagNameMap.put(new Integer(TAG_X_RESOLUTION), "X Resolution");
+        tagNameMap.put(new Integer(TAG_Y_RESOLUTION), "Y Resolution");
+        tagNameMap.put(new Integer(TAG_PAGE_NAME), "Page Name");
+        tagNameMap.put(new Integer(TAG_PLANAR_CONFIGURATION), "Planar Configuration");
+        tagNameMap.put(new Integer(TAG_RESOLUTION_UNIT), "Resolution Unit");
+        tagNameMap.put(new Integer(TAG_TRANSFER_FUNCTION), "Transfer Function");
+        tagNameMap.put(new Integer(TAG_SOFTWARE), "Software");
+        tagNameMap.put(new Integer(TAG_DATETIME), "Date/Time");
+        tagNameMap.put(new Integer(TAG_ARTIST), "Artist");
+        tagNameMap.put(new Integer(TAG_PREDICTOR), "Predictor");
+        tagNameMap.put(new Integer(TAG_WHITE_POINT), "White Point");
+        tagNameMap.put(new Integer(TAG_PRIMARY_CHROMATICITIES), "Primary Chromaticities");
+        tagNameMap.put(new Integer(TAG_TILE_WIDTH), "Tile Width");
+        tagNameMap.put(new Integer(TAG_TILE_LENGTH), "Tile Length");
+        tagNameMap.put(new Integer(TAG_TILE_OFFSETS), "Tile Offsets");
+        tagNameMap.put(new Integer(TAG_TILE_BYTE_COUNTS), "Tile Byte Counts");
+        tagNameMap.put(new Integer(TAG_SUB_IFDS), "Sub IFDs");
+        tagNameMap.put(new Integer(TAG_JPEG_TABLES), "JPEG Tables");
+        tagNameMap.put(new Integer(TAG_THUMBNAIL_OFFSET), "Thumbnail Offset");
+        tagNameMap.put(new Integer(TAG_THUMBNAIL_LENGTH), "Thumbnail Length");
+        tagNameMap.put(new Integer(TAG_THUMBNAIL_DATA), "Thumbnail Data");
+        tagNameMap.put(new Integer(TAG_YCBCR_COEFFICIENTS), "YCbCr Coefficients");
+        tagNameMap.put(new Integer(TAG_YCBCR_SUBSAMPLING), "YCbCr Sub-Sampling");
+        tagNameMap.put(new Integer(TAG_YCBCR_POSITIONING), "YCbCr Positioning");
+        tagNameMap.put(new Integer(TAG_REFERENCE_BLACK_WHITE), "Reference Black/White");
+        tagNameMap.put(new Integer(TAG_CFA_REPEAT_PATTERN_DIM), "CFA Repeat Pattern Dim");
+        tagNameMap.put(new Integer(TAG_CFA_PATTERN_2), "CFA Pattern");
+        tagNameMap.put(new Integer(TAG_BATTERY_LEVEL), "Battery Level");
+        tagNameMap.put(new Integer(TAG_COPYRIGHT), "Copyright");
+        tagNameMap.put(new Integer(TAG_EXPOSURE_TIME), "Exposure Time");
+        tagNameMap.put(new Integer(TAG_FNUMBER), "F-Number");
+        tagNameMap.put(new Integer(TAG_IPTC_NAA), "IPTC/NAA");
+        tagNameMap.put(new Integer(TAG_INTER_COLOR_PROFILE), "Inter Color Profile");
+        tagNameMap.put(new Integer(TAG_EXPOSURE_PROGRAM), "Exposure Program");
+        tagNameMap.put(new Integer(TAG_SPECTRAL_SENSITIVITY), "Spectral Sensitivity");
+        tagNameMap.put(new Integer(TAG_GPS_INFO), "GPS Info");
+        tagNameMap.put(new Integer(TAG_ISO_EQUIVALENT), "ISO Speed Ratings");
+        tagNameMap.put(new Integer(TAG_OECF), "OECF");
+        tagNameMap.put(new Integer(TAG_INTERLACE), "Interlace");
+        tagNameMap.put(new Integer(TAG_TIME_ZONE_OFFSET), "Time Zone Offset");
+        tagNameMap.put(new Integer(TAG_SELF_TIMER_MODE), "Self Timer Mode");
+        tagNameMap.put(new Integer(TAG_EXIF_VERSION), "Exif Version");
+        tagNameMap.put(new Integer(TAG_DATETIME_ORIGINAL), "Date/Time Original");
+        tagNameMap.put(new Integer(TAG_DATETIME_DIGITIZED), "Date/Time Digitized");
+        tagNameMap.put(new Integer(TAG_COMPONENTS_CONFIGURATION), "Components Configuration");
+        tagNameMap.put(new Integer(TAG_SHUTTER_SPEED), "Shutter Speed Value");
+        tagNameMap.put(new Integer(TAG_APERTURE), "Aperture Value");
+        tagNameMap.put(new Integer(TAG_BRIGHTNESS_VALUE), "Brightness Value");
+        tagNameMap.put(new Integer(TAG_EXPOSURE_BIAS), "Exposure Bias Value");
+        tagNameMap.put(new Integer(TAG_MAX_APERTURE), "Max Aperture Value");
+        tagNameMap.put(new Integer(TAG_SUBJECT_DISTANCE), "Subject Distance");
+        tagNameMap.put(new Integer(TAG_METERING_MODE), "Metering Mode");
+        tagNameMap.put(new Integer(TAG_WHITE_BALANCE), "Light Source");
+        tagNameMap.put(new Integer(TAG_FLASH), "Flash");
+        tagNameMap.put(new Integer(TAG_FOCAL_LENGTH), "Focal Length");
+        tagNameMap.put(new Integer(TAG_FLASH_ENERGY), "Flash Energy");
+        tagNameMap.put(new Integer(TAG_SPATIAL_FREQ_RESPONSE), "Spatial Frequency Response");
+        tagNameMap.put(new Integer(TAG_NOISE), "Noise");
+        tagNameMap.put(new Integer(TAG_IMAGE_NUMBER), "Image Number");
+        tagNameMap.put(new Integer(TAG_SECURITY_CLASSIFICATION), "Security Classification");
+        tagNameMap.put(new Integer(TAG_IMAGE_HISTORY), "Image History");
+        tagNameMap.put(new Integer(TAG_SUBJECT_LOCATION), "Subject Location");
+        tagNameMap.put(new Integer(TAG_EXPOSURE_INDEX), "Exposure Index");
+        tagNameMap.put(new Integer(TAG_TIFF_EP_STANDARD_ID), "TIFF/EP Standard ID");
+        tagNameMap.put(new Integer(TAG_USER_COMMENT), "User Comment");
+        tagNameMap.put(new Integer(TAG_SUBSECOND_TIME), "Sub-Sec Time");
+        tagNameMap.put(new Integer(TAG_SUBSECOND_TIME_ORIGINAL), "Sub-Sec Time Original");
+        tagNameMap.put(new Integer(TAG_SUBSECOND_TIME_DIGITIZED), "Sub-Sec Time Digitized");
+        tagNameMap.put(new Integer(TAG_FLASHPIX_VERSION), "FlashPix Version");
+        tagNameMap.put(new Integer(TAG_COLOR_SPACE), "Color Space");
+        tagNameMap.put(new Integer(TAG_EXIF_IMAGE_WIDTH), "Exif Image Width");
+        tagNameMap.put(new Integer(TAG_EXIF_IMAGE_HEIGHT), "Exif Image Height");
+        tagNameMap.put(new Integer(TAG_RELATED_SOUND_FILE), "Related Sound File");
+        // 0x920B in TIFF/EP
+        tagNameMap.put(new Integer(TAG_FLASH_ENERGY_2), "Flash Energy");
+        // 0x920C in TIFF/EP
+        tagNameMap.put(new Integer(TAG_SPATIAL_FREQ_RESPONSE_2), "Spatial Frequency Response");
+        // 0x920E in TIFF/EP
+        tagNameMap.put(new Integer(TAG_FOCAL_PLANE_X_RES), "Focal Plane X Resolution");
+        // 0x920F in TIFF/EP
+        tagNameMap.put(new Integer(TAG_FOCAL_PLANE_Y_RES), "Focal Plane Y Resolution");
+        // 0x9210 in TIFF/EP
+        tagNameMap.put(new Integer(TAG_FOCAL_PLANE_UNIT), "Focal Plane Resolution Unit");
+        // 0x9214 in TIFF/EP
+        tagNameMap.put(new Integer(TAG_SUBJECT_LOCATION_2), "Subject Location");
+        // 0x9215 in TIFF/EP
+        tagNameMap.put(new Integer(TAG_EXPOSURE_INDEX_2), "Exposure Index");
+        // 0x9217 in TIFF/EP
+        tagNameMap.put(new Integer(TAG_SENSING_METHOD), "Sensing Method");
+        tagNameMap.put(new Integer(TAG_FILE_SOURCE), "File Source");
+        tagNameMap.put(new Integer(TAG_SCENE_TYPE), "Scene Type");
+        tagNameMap.put(new Integer(TAG_CFA_PATTERN), "CFA Pattern");
+
+        tagNameMap.put(new Integer(TAG_CUSTOM_RENDERED), "Custom Rendered");
+        tagNameMap.put(new Integer(TAG_EXPOSURE_MODE), "Exposure Mode");
+        tagNameMap.put(new Integer(TAG_WHITE_BALANCE_MODE), "White Balance");
+        tagNameMap.put(new Integer(TAG_DIGITAL_ZOOM_RATIO), "Digital Zoom Ratio");
+        tagNameMap.put(new Integer(TAG_35MM_FILM_EQUIV_FOCAL_LENGTH), "Focal Length 35");
+        tagNameMap.put(new Integer(TAG_SCENE_CAPTURE_TYPE), "Scene Capture Type");
+        tagNameMap.put(new Integer(TAG_GAIN_CONTROL), "Gain Control");
+        tagNameMap.put(new Integer(TAG_CONTRAST), "Contrast");
+        tagNameMap.put(new Integer(TAG_SATURATION), "Saturation");
+        tagNameMap.put(new Integer(TAG_SHARPNESS), "Sharpness");
+        tagNameMap.put(new Integer(TAG_DEVICE_SETTING_DESCRIPTION), "Device Setting Description");
+        tagNameMap.put(new Integer(TAG_SUBJECT_DISTANCE_RANGE), "Subject Distance Range");
+
+        tagNameMap.put(new Integer(TAG_WIN_AUTHOR), "Windows XP Author");
+        tagNameMap.put(new Integer(TAG_WIN_COMMENT), "Windows XP Comment");
+        tagNameMap.put(new Integer(TAG_WIN_KEYWORDS), "Windows XP Keywords");
+        tagNameMap.put(new Integer(TAG_WIN_SUBJECT), "Windows XP Subject");
+        tagNameMap.put(new Integer(TAG_WIN_TITLE), "Windows XP Title");
+
+        tagNameMap.put(new Integer(TAG_MIN_SAMPLE_VALUE), "Minimum sample value");
+        tagNameMap.put(new Integer(TAG_MAX_SAMPLE_VALUE), "Maximum sample value");
+    }
+
+    public ExifDirectory()
+    {
+        this.setDescriptor(new ExifDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "Exif";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return tagNameMap;
+    }
+
+    public byte[] getThumbnailData() throws MetadataException
+    {
+        if (!containsThumbnail())
+            return null;
+        
+        return this.getByteArray(ExifDirectory.TAG_THUMBNAIL_DATA);
+    }
+
+    public void writeThumbnail(String filename) throws MetadataException, IOException
+    {
+        byte[] data = getThumbnailData();
+
+        if (data==null)
+            throw new MetadataException("No thumbnail data exists.");
+
+        FileOutputStream stream = null;
+        try {
+            stream = new FileOutputStream(filename);
+            stream.write(data);
+        } finally {
+            if (stream!=null)
+                stream.close();
+        }
+    }
+
+/*
+    // This thumbnail extraction code is not complete, and is included to assist anyone who feels like looking into
+    // it.  Please share any progress with the original author, and hence the community.  Thanks.
+
+    /**
+     *
+     * @return
+     * @throws MetadataException
+     * /
+    public Image getThumbnailImage() throws MetadataException
+    {
+        if (!containsThumbnail())
+            return null;
+
+        int compression = 0;
+        try {
+        	compression = this.getInt(ExifDirectory.TAG_COMPRESSION);
+        } catch (Throwable e) {
+        	this.addError("Unable to determine thumbnail type " + e.getMessage());
+        }
+
+        final byte[] thumbnailBytes = getThumbnailData();
+
+        if (compression == ExifDirectory.COMPRESSION_JPEG)
+        {
+            // JPEG Thumbnail
+            // operate directly on thumbnailBytes
+//            try {
+//                int offset = this.getInt(ExifDirectory.TAG_THUMBNAIL_OFFSET);
+//                int length = this.getInt(ExifDirectory.TAG_THUMBNAIL_LENGTH);
+//                byte[] result = new byte[length];
+//                for (int i = 0; i<result.length; i++) {
+//                    result[i] = _data[tiffHeaderOffset + offset + i];
+//                }
+//                this.setByteArray(ExifDirectory.TAG_THUMBNAIL_DATA, result);
+//            } catch (Throwable e) {
+//                this.addError("Unable to extract thumbnail: " + e.getMessage());
+//            }
+            // TODO decode the JPEG bytes as an image
+            return null; //new Image();
+        }
+        else if (compression == ExifDirectory.COMPRESSION_NONE)
+        {
+            // uncompressed thumbnail (raw RGB data)
+        	if (!this.containsTag(ExifDirectory.TAG_PHOTOMETRIC_INTERPRETATION))
+	            return null;
+
+        	try
+            {
+        		// If the image is RGB format, then convert it to a bitmap
+                final int photometricInterpretation = this.getInt(ExifDirectory.TAG_PHOTOMETRIC_INTERPRETATION);
+                if (photometricInterpretation == ExifDirectory.PHOTOMETRIC_INTERPRETATION_RGB)
+                {
+                    // RGB
+                    Image image = createImageFromRawRgb(thumbnailBytes);
+                    return image;
+        		}
+                else if (photometricInterpretation == ExifDirectory.PHOTOMETRIC_INTERPRETATION_YCBCR)
+                {
+                    // YCbCr
+                    Image image = createImageFromRawYCbCr(thumbnailBytes);
+                    return image;
+        		}
+                else if (photometricInterpretation == ExifDirectory.PHOTOMETRIC_INTERPRETATION_MONOCHROME)
+                {
+                    // Monochrome
+                    // TODO
+                    return null;
+                }
+	        } catch (Throwable e) {
+	            this.addError("Unable to extract thumbnail: " + e.getMessage());
+	        }
+        }
+        return null;
+    }
+
+    /**
+     * Handle the YCbCr thumbnail encoding used by Ricoh RDC4200/4300, Fuji DS-7/300 and DX-5/7/9 cameras.
+     *
+     * At DX-5/7/9, YCbCrSubsampling(0x0212) has values of '2,1', PlanarConfiguration(0x011c) has a value '1'. So the
+     * data align of this image is below.
+     *
+     * Y(0,0),Y(1,0),Cb(0,0),Cr(0,0), Y(2,0),Y(3,0),Cb(2,0),Cr(3.0), Y(4,0),Y(5,0),Cb(4,0),Cr(4,0). . . .
+     *
+     * The numerics in parenthesis are pixel coordinates. DX series' YCbCrCoefficients(0x0211) has values '0.299/0.587/0.114',
+     * ReferenceBlackWhite(0x0214) has values '0,255,128,255,128,255'. Therefore to convert from Y/Cb/Cr to RGB is;
+     *
+     * B(0,0)=(Cb-128)*(2-0.114*2)+Y(0,0)
+     * R(0,0)=(Cr-128)*(2-0.299*2)+Y(0,0)
+     * G(0,0)=(Y(0,0)-0.114*B(0,0)-0.299*R(0,0))/0.587
+     *
+     * Horizontal subsampling is a value '2', so you can calculate B(1,0)/R(1,0)/G(1,0) by using the Y(1,0) and Cr(0,0)/Cb(0,0).
+     * Repeat this conversion by value of ImageWidth(0x0100) and ImageLength(0x0101).
+     *
+     * @param thumbnailBytes
+     * @return
+     * @throws com.drew.metadata.MetadataException
+     * /
+    private Image createImageFromRawYCbCr(byte[] thumbnailBytes) throws MetadataException
+    {
+        /*
+            Y  =  0.257R + 0.504G + 0.098B + 16
+            Cb = -0.148R - 0.291G + 0.439B + 128
+            Cr =  0.439R - 0.368G - 0.071B + 128
+
+            G = 1.164(Y-16) - 0.391(Cb-128) - 0.813(Cr-128)
+            R = 1.164(Y-16) + 1.596(Cr-128)
+            B = 1.164(Y-16) + 2.018(Cb-128)
+
+            R, G and B range from 0 to 255.
+            Y ranges from 16 to 235.
+            Cb and Cr range from 16 to 240.
+
+            http://www.faqs.org/faqs/graphics/colorspace-faq/
+        * /
+
+        int length = thumbnailBytes.length; // this.getInt(ExifDirectory.TAG_STRIP_BYTE_COUNTS);
+        final int imageWidth = this.getInt(ExifDirectory.TAG_THUMBNAIL_IMAGE_WIDTH);
+        final int imageHeight = this.getInt(ExifDirectory.TAG_THUMBNAIL_IMAGE_HEIGHT);
+//        final int headerLength = 54;
+//        byte[] result = new byte[length + headerLength];
+//        // Add a windows BMP header described:
+//        // http://www.onicos.com/staff/iz/formats/bmp.html
+//        result[0] = 'B';
+//        result[1] = 'M'; // File Type identifier
+//        result[3] = (byte)(result.length / 256);
+//        result[2] = (byte)result.length;
+//        result[10] = (byte)headerLength;
+//        result[14] = 40; // MS Windows BMP header
+//        result[18] = (byte)imageWidth;
+//        result[22] = (byte)imageHeight;
+//        result[26] = 1;  // 1 Plane
+//        result[28] = 24; // Colour depth
+//        result[34] = (byte)length;
+//        result[35] = (byte)(length / 256);
+
+        final BufferedImage image = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_RGB);
+
+        // order is YCbCr and image is upside down, bitmaps are BGR
+////        for (int i = headerLength, dataOffset = length; i<result.length; i += 3, dataOffset -= 3)
+//        {
+//            final int y =  thumbnailBytes[dataOffset - 2] & 0xFF;
+//            final int cb = thumbnailBytes[dataOffset - 1] & 0xFF;
+//            final int cr = thumbnailBytes[dataOffset] & 0xFF;
+//            if (y<16 || y>235 || cb<16 || cb>240 || cr<16 || cr>240)
+//                "".toString();
+//
+//            int g = (int)(1.164*(y-16) - 0.391*(cb-128) - 0.813*(cr-128));
+//            int r = (int)(1.164*(y-16) + 1.596*(cr-128));
+//            int b = (int)(1.164*(y-16) + 2.018*(cb-128));
+//
+////            result[i] = (byte)b;
+////            result[i + 1] = (byte)g;
+////            result[i + 2] = (byte)r;
+//
+//            // TODO compose the image here
+//            image.setRGB(1, 2, 3);
+//        }
+
+        return image;
+    }
+
+    /**
+     * Creates a thumbnail image in (Windows) BMP format from raw RGB data.
+     * @param thumbnailBytes
+     * @return
+     * @throws com.drew.metadata.MetadataException
+     * /
+    private Image createImageFromRawRgb(byte[] thumbnailBytes) throws MetadataException
+    {
+        final int length = thumbnailBytes.length; // this.getInt(ExifDirectory.TAG_STRIP_BYTE_COUNTS);
+        final int imageWidth = this.getInt(ExifDirectory.TAG_THUMBNAIL_IMAGE_WIDTH);
+        final int imageHeight = this.getInt(ExifDirectory.TAG_THUMBNAIL_IMAGE_HEIGHT);
+//        final int headerlength = 54;
+//        final byte[] result = new byte[length + headerlength];
+//        // Add a windows BMP header described:
+//        // http://www.onicos.com/staff/iz/formats/bmp.html
+//        result[0] = 'B';
+//        result[1] = 'M'; // File Type identifier
+//        result[3] = (byte)(result.length / 256);
+//        result[2] = (byte)result.length;
+//        result[10] = (byte)headerlength;
+//        result[14] = 40; // MS Windows BMP header
+//        result[18] = (byte)imageWidth;
+//        result[22] = (byte)imageHeight;
+//        result[26] = 1;  // 1 Plane
+//        result[28] = 24; // Colour depth
+//        result[34] = (byte)length;
+//        result[35] = (byte)(length / 256);
+
+        final BufferedImage image = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_RGB);
+
+        // order is RGB and image is upside down, bitmaps are BGR
+//        for (int i = headerlength, dataOffset = length; i<result.length; i += 3, dataOffset -= 3)
+//        {
+//            byte b = thumbnailBytes[dataOffset - 2];
+//            byte g = thumbnailBytes[dataOffset - 1];
+//            byte r = thumbnailBytes[dataOffset];
+//
+//            // TODO compose the image here
+//            image.setRGB(1, 2, 3);
+//        }
+
+        return image;
+    }
+*/
+
+    public boolean containsThumbnail()
+    {
+        return containsTag(ExifDirectory.TAG_THUMBNAIL_DATA);
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/ExifInteropDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/ExifInteropDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/ExifInteropDescriptor.java	(revision 4231)
@@ -0,0 +1,62 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 12-Nov-2002 22:27:34 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ *
+ */
+public class ExifInteropDescriptor extends TagDescriptor
+{
+    public ExifInteropDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType) {
+            case ExifInteropDirectory.TAG_INTEROP_INDEX:
+                return getInteropIndexDescription();
+            case ExifInteropDirectory.TAG_INTEROP_VERSION:
+                return getInteropVersionDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getInteropVersionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(ExifInteropDirectory.TAG_INTEROP_VERSION)) return null;
+        int[] ints = _directory.getIntArray(ExifInteropDirectory.TAG_INTEROP_VERSION);
+        return ExifDescriptor.convertBytesToVersionString(ints);
+    }
+
+    public String getInteropIndexDescription()
+    {
+        if (!_directory.containsTag(ExifInteropDirectory.TAG_INTEROP_INDEX)) return null;
+        String interopIndex = _directory.getString(ExifInteropDirectory.TAG_INTEROP_INDEX).trim();
+        if ("R98".equalsIgnoreCase(interopIndex)) {
+            return "Recommended Exif Interoperability Rules (ExifR98)";
+        } else {
+            return "Unknown (" + interopIndex + ")";
+        }
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/ExifInteropDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/ExifInteropDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/ExifInteropDirectory.java	(revision 4231)
@@ -0,0 +1,60 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 26-Nov-2002 10:58:13 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ *
+ */
+public class ExifInteropDirectory extends Directory
+{
+    public static final int TAG_INTEROP_INDEX = 0x0001;
+    public static final int TAG_INTEROP_VERSION = 0x0002;
+    public static final int TAG_RELATED_IMAGE_FILE_FORMAT = 0x1000;
+    public static final int TAG_RELATED_IMAGE_WIDTH = 0x1001;
+    public static final int TAG_RELATED_IMAGE_LENGTH = 0x1002;
+
+    protected static final HashMap tagNameMap;
+
+    static
+    {
+        tagNameMap = new HashMap();
+        tagNameMap.put(new Integer(TAG_INTEROP_INDEX), "Interoperability Index");
+        tagNameMap.put(new Integer(TAG_INTEROP_VERSION), "Interoperability Version");
+        tagNameMap.put(new Integer(TAG_RELATED_IMAGE_FILE_FORMAT), "Related Image File Format");
+        tagNameMap.put(new Integer(TAG_RELATED_IMAGE_WIDTH), "Related Image Width");
+        tagNameMap.put(new Integer(TAG_RELATED_IMAGE_LENGTH), "Related Image Length");
+    }
+
+    public ExifInteropDirectory()
+    {
+        this.setDescriptor(new ExifInteropDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "Interoperability";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return tagNameMap;
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/ExifProcessingException.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/ExifProcessingException.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/ExifProcessingException.java	(revision 4231)
@@ -0,0 +1,50 @@
+/*
+ * ExifProcessingException.java
+ *
+ * This class is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created on 29 April 2002, 00:33
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.MetadataException;
+
+/**
+ * The exception type raised during reading of Exif data in the instance of
+ * unexpected data conditions.
+ * @author  Drew Noakes http://drewnoakes.com
+ */
+public class ExifProcessingException extends MetadataException
+{
+    /**
+     * Constructs an instance of <code>ExifProcessingException</code> with the
+     * specified detail message.
+     * @param message the detail message
+     */
+    public ExifProcessingException(String message)
+    {
+        super(message);
+    }
+
+    /**
+     * Constructs an instance of <code>ExifProcessingException</code> with the
+     * specified detail message and inner exception.
+     * @param message the detail message
+     * @param cause an inner exception
+     */
+    public ExifProcessingException(String message, Throwable cause)
+    {
+        super(message, cause);
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/ExifReader.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/ExifReader.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/ExifReader.java	(revision 4231)
@@ -0,0 +1,668 @@
+/*
+ * EXIFExtractor.java
+ *
+ * This class based upon code from Jhead, a C program for extracting and
+ * manipulating the Exif data within files written by Matthias Wandel.
+ *   http://www.sentex.net/~mwandel/jhead/
+ *
+ * Jhead is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  Similarly, I release this Java version under the
+ * same license, though I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.  Unlike
+ * Jhead, this code (as it stands) only supports reading of Exif data - no
+ * manipulation, and no thumbnail stuff.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew.noakes@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created on 28 April 2002, 23:54
+ * Modified 04 Aug 2002
+ * - Renamed constants to be inline with changes to ExifTagValues interface
+ * - Substituted usage of JDK 1.4 features (java.nio package)
+ * Modified 29 Oct 2002 (v1.2)
+ * - Proper traversing of Exif file structure and complete refactor & tidy of
+ *   the codebase (a few unnoticed bugs removed)
+ * - Reads makernote data for 6 families of camera (5 makes)
+ * - Tags now stored in directories... use the IFD_* constants to refer to the
+ *   image file directory you require (Exif, Interop, GPS and Makernote*) --
+ *   this avoids collisions where two tags share the same code
+ * - Takes componentCount of unknown tags into account
+ * - Now understands GPS tags (thanks to Colin Briton for his help with this)
+ * - Some other bug fixes, pointed out by users around the world.  Thanks!
+ * Modified 27 Nov 2002 (v2.0)
+ * - Renamed to ExifReader
+ * - Moved to new package com.drew.metadata.exif
+ * Modified since, however changes have not been logged.  See release notes for
+ * library-wide modifications.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.imaging.jpeg.JpegProcessingException;
+import com.drew.imaging.jpeg.JpegSegmentData;
+import com.drew.imaging.jpeg.JpegSegmentReader;
+import com.drew.lang.Rational;
+import com.drew.metadata.Directory;
+import com.drew.metadata.Metadata;
+import com.drew.metadata.MetadataReader;
+
+import java.io.File;
+import java.io.InputStream;
+import java.util.HashMap;
+
+/**
+ * Extracts Exif data from a JPEG header segment, providing information about the
+ * camera/scanner/capture device (if available).  Information is encapsulated in
+ * an <code>Metadata</code> object.
+ * @author  Drew Noakes http://drewnoakes.com
+ */
+public class ExifReader implements MetadataReader
+{
+    /**
+     * The JPEG segment as an array of bytes.
+     */
+    private final byte[] _data;
+
+    /**
+     * Represents the native byte ordering used in the JPEG segment.  If true,
+     * then we're using Motorolla ordering (Big endian), else we're using Intel
+     * ordering (Little endian).
+     */
+    private boolean _isMotorollaByteOrder;
+
+    /**
+     * Bean instance to store information about the image and camera/scanner/capture
+     * device.
+     */
+    private Metadata _metadata;
+
+    /**
+     * The number of bytes used per format descriptor.
+     */
+    private static final int[] BYTES_PER_FORMAT = {0, 1, 1, 2, 4, 8, 1, 1, 2, 4, 8, 4, 8};
+
+    /**
+     * The number of formats known.
+     */
+    private static final int MAX_FORMAT_CODE = 12;
+
+    // Format types
+    // Note: Cannot use the DataFormat enumeration in the case statement that uses these tags.
+    //       Is there a better way?
+    private static final int FMT_BYTE = 1;
+    private static final int FMT_STRING = 2;
+    private static final int FMT_USHORT = 3;
+    private static final int FMT_ULONG = 4;
+    private static final int FMT_URATIONAL = 5;
+    private static final int FMT_SBYTE = 6;
+    private static final int FMT_UNDEFINED = 7;
+    private static final int FMT_SSHORT = 8;
+    private static final int FMT_SLONG = 9;
+    private static final int FMT_SRATIONAL = 10;
+    private static final int FMT_SINGLE = 11;
+    private static final int FMT_DOUBLE = 12;
+
+    public static final int TAG_EXIF_OFFSET = 0x8769;
+    public static final int TAG_INTEROP_OFFSET = 0xA005;
+    public static final int TAG_GPS_INFO_OFFSET = 0x8825;
+    public static final int TAG_MAKER_NOTE = 0x927C;
+
+    public static final int TIFF_HEADER_START_OFFSET = 6;
+
+    /**
+     * Creates an ExifReader for a JpegSegmentData object.
+     * @param segmentData
+     */
+    public ExifReader(JpegSegmentData segmentData)
+    {
+        this(segmentData.getSegment(JpegSegmentReader.SEGMENT_APP1));
+    }
+
+    /**
+     * Creates an ExifReader for a Jpeg file.
+     * @param file
+     * @throws JpegProcessingException
+     */
+    public ExifReader(File file) throws JpegProcessingException
+    {
+        this(new JpegSegmentReader(file).readSegment(JpegSegmentReader.SEGMENT_APP1));
+    }
+
+    /**
+     * Creates an ExifReader for a Jpeg stream.
+     * @param is JPEG stream. Stream will be closed.
+     */
+    public ExifReader(InputStream is) throws JpegProcessingException
+    {
+        this(new JpegSegmentReader(is).readSegment(JpegSegmentReader.SEGMENT_APP1));
+    }
+
+    /**
+     * Creates an ExifReader for the given JPEG header segment.
+     */
+    public ExifReader(byte[] data)
+    {
+        _data = data;
+    }
+
+    /**
+     * Performs the Exif data extraction, returning a new instance of <code>Metadata</code>.
+     */
+    public Metadata extract()
+    {
+        return extract(new Metadata());
+    }
+
+    /**
+     * Performs the Exif data extraction, adding found values to the specified
+     * instance of <code>Metadata</code>.
+     */
+    public Metadata extract(Metadata metadata)
+    {
+        _metadata = metadata;
+        if (_data==null)
+            return _metadata;
+
+        // once we know there's some data, create the directory and start working on it
+        ExifDirectory directory = (ExifDirectory)_metadata.getDirectory(ExifDirectory.class);
+
+        // check for the header length
+        if (_data.length<=14) {
+            directory.addError("Exif data segment must contain at least 14 bytes");
+            return _metadata;
+        }
+
+        // check for the header preamble
+        if (!"Exif\0\0".equals(new String(_data, 0, 6))) {
+            directory.addError("Exif data segment doesn't begin with 'Exif'");
+            return _metadata;
+        }
+
+        // this should be either "MM" or "II"
+        String byteOrderIdentifier = new String(_data, 6, 2);
+        if (!setByteOrder(byteOrderIdentifier)) {
+            directory.addError("Unclear distinction between Motorola/Intel byte ordering: " + byteOrderIdentifier);
+            return _metadata;
+        }
+
+        // Check the next two values for correctness.
+        if (get16Bits(8)!=0x2a) {
+            directory.addError("Invalid Exif start - should have 0x2A at offset 8 in Exif header");
+            return _metadata;
+        }
+
+        int firstDirectoryOffset = get32Bits(10) + TIFF_HEADER_START_OFFSET;
+
+        // David Ekholm sent an digital camera image that has this problem
+        if (firstDirectoryOffset>=_data.length - 1) {
+            directory.addError("First exif directory offset is beyond end of Exif data segment");
+            // First directory normally starts 14 bytes in -- try it here and catch another error in the worst case
+            firstDirectoryOffset = 14;
+        }
+
+        HashMap processedDirectoryOffsets = new HashMap();
+
+        // 0th IFD (we merge with Exif IFD)
+        processDirectory(directory, processedDirectoryOffsets, firstDirectoryOffset, TIFF_HEADER_START_OFFSET);
+
+        // after the extraction process, if we have the correct tags, we may be able to store thumbnail information
+        storeThumbnailBytes(directory, TIFF_HEADER_START_OFFSET);
+
+        return _metadata;
+    }
+
+    private void storeThumbnailBytes(ExifDirectory exifDirectory, int tiffHeaderOffset)
+    {
+        if (!exifDirectory.containsTag(ExifDirectory.TAG_COMPRESSION))
+        	return;
+
+        if (!exifDirectory.containsTag(ExifDirectory.TAG_THUMBNAIL_LENGTH) ||
+            !exifDirectory.containsTag(ExifDirectory.TAG_THUMBNAIL_OFFSET))
+            return;
+
+        try {
+            int offset = exifDirectory.getInt(ExifDirectory.TAG_THUMBNAIL_OFFSET);
+            int length = exifDirectory.getInt(ExifDirectory.TAG_THUMBNAIL_LENGTH);
+            byte[] result = new byte[length];
+            for (int i = 0; i<result.length; i++) {
+                result[i] = _data[tiffHeaderOffset + offset + i];
+            }
+            exifDirectory.setByteArray(ExifDirectory.TAG_THUMBNAIL_DATA, result);
+        } catch (Throwable e) {
+            exifDirectory.addError("Unable to extract thumbnail: " + e.getMessage());
+        }
+    }
+
+    private boolean setByteOrder(String byteOrderIdentifier)
+    {
+        if ("MM".equals(byteOrderIdentifier)) {
+            _isMotorollaByteOrder = true;
+        } else if ("II".equals(byteOrderIdentifier)) {
+            _isMotorollaByteOrder = false;
+        } else {
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Process one of the nested Tiff IFD directories.
+     * 2 bytes: number of tags
+     * for each tag
+     *   2 bytes: tag type
+     *   2 bytes: format code
+     *   4 bytes: component count
+     */
+    private void processDirectory(Directory directory, HashMap processedDirectoryOffsets, int dirStartOffset, int tiffHeaderOffset)
+    {
+        // check for directories we've already visited to avoid stack overflows when recursive/cyclic directory structures exist
+        if (processedDirectoryOffsets.containsKey(new Integer(dirStartOffset)))
+            return;
+
+        // remember that we've visited this directory so that we don't visit it again later
+        processedDirectoryOffsets.put(new Integer(dirStartOffset), "processed");
+
+        if (dirStartOffset>=_data.length || dirStartOffset<0) {
+            directory.addError("Ignored directory marked to start outside data segement");
+            return;
+        }
+
+        if (!isDirectoryLengthValid(dirStartOffset, tiffHeaderOffset)) {
+            directory.addError("Illegally sized directory");
+            return;
+        }
+
+        // First two bytes in the IFD are the number of tags in this directory
+        int dirTagCount = get16Bits(dirStartOffset);
+
+        // Handle each tag in this directory
+        for (int tagNumber = 0; tagNumber<dirTagCount; tagNumber++)
+        {
+            final int tagOffset = calculateTagOffset(dirStartOffset, tagNumber);
+
+            // 2 bytes for the tag type
+            final int tagType = get16Bits(tagOffset);
+
+            // 2 bytes for the format code
+            final int formatCode = get16Bits(tagOffset + 2);
+            if (formatCode<1 || formatCode>MAX_FORMAT_CODE) {
+                directory.addError("Invalid format code: " + formatCode);
+                continue;
+            }
+
+            // 4 bytes dictate the number of components in this tag's data
+            final int componentCount = get32Bits(tagOffset + 4);
+            if (componentCount<0) {
+                directory.addError("Negative component count in EXIF");
+                continue;
+            }
+            // each component may have more than one byte... calculate the total number of bytes
+            final int byteCount = componentCount * BYTES_PER_FORMAT[formatCode];
+            final int tagValueOffset = calculateTagValueOffset(byteCount, tagOffset, tiffHeaderOffset);
+            if (tagValueOffset<0 || tagValueOffset > _data.length) {
+                directory.addError("Illegal pointer offset value in EXIF");
+                continue;
+            }
+
+            // Check that this tag isn't going to allocate outside the bounds of the data array.
+            // This addresses an uncommon OutOfMemoryError.
+            if (byteCount < 0 || tagValueOffset + byteCount > _data.length)
+            {
+                directory.addError("Illegal number of bytes: " + byteCount);
+                continue;
+            }
+
+            // Calculate the value as an offset for cases where the tag represents directory
+            final int subdirOffset = tiffHeaderOffset + get32Bits(tagValueOffset);
+
+            switch (tagType) {
+                case TAG_EXIF_OFFSET:
+                    processDirectory(_metadata.getDirectory(ExifDirectory.class), processedDirectoryOffsets, subdirOffset, tiffHeaderOffset);
+                    continue;
+                case TAG_INTEROP_OFFSET:
+                    processDirectory(_metadata.getDirectory(ExifInteropDirectory.class), processedDirectoryOffsets, subdirOffset, tiffHeaderOffset);
+                    continue;
+                case TAG_GPS_INFO_OFFSET:
+                    processDirectory(_metadata.getDirectory(GpsDirectory.class), processedDirectoryOffsets, subdirOffset, tiffHeaderOffset);
+                    continue;
+                case TAG_MAKER_NOTE:
+                    processMakerNote(tagValueOffset, processedDirectoryOffsets, tiffHeaderOffset);
+                    continue;
+                default:
+                    processTag(directory, tagType, tagValueOffset, componentCount, formatCode);
+                    break;
+            }
+        }
+
+        // at the end of each IFD is an optional link to the next IFD
+        final int finalTagOffset = calculateTagOffset(dirStartOffset, dirTagCount);
+        int nextDirectoryOffset = get32Bits(finalTagOffset);
+        if (nextDirectoryOffset!=0) {
+            nextDirectoryOffset += tiffHeaderOffset;
+            if (nextDirectoryOffset>=_data.length) {
+                // Last 4 bytes of IFD reference another IFD with an address that is out of bounds
+                // Note this could have been caused by jhead 1.3 cropping too much
+                return;
+            } else if (nextDirectoryOffset < dirStartOffset) {
+                // Last 4 bytes of IFD reference another IFD with an address that is before the start of this directory
+                return;
+            }
+            // the next directory is of same type as this one
+            processDirectory(directory, processedDirectoryOffsets, nextDirectoryOffset, tiffHeaderOffset);
+        }
+    }
+
+    private void processMakerNote(int subdirOffset, HashMap processedDirectoryOffsets, int tiffHeaderOffset)
+    {
+        // Determine the camera model and makernote format
+        Directory exifDirectory = _metadata.getDirectory(ExifDirectory.class);
+
+        if (exifDirectory==null)
+            return;
+
+        String cameraModel = exifDirectory.getString(ExifDirectory.TAG_MAKE);
+        final String firstTwoChars = new String(_data, subdirOffset, 2);
+        final String firstThreeChars = new String(_data, subdirOffset, 3);
+        final String firstFourChars = new String(_data, subdirOffset, 4);
+        final String firstFiveChars = new String(_data, subdirOffset, 5);
+        final String firstSixChars = new String(_data, subdirOffset, 6);
+        final String firstSevenChars = new String(_data, subdirOffset, 7);
+        final String firstEightChars = new String(_data, subdirOffset, 8);
+        if ("OLYMP".equals(firstFiveChars) || "EPSON".equals(firstFiveChars) || "AGFA".equals(firstFourChars))
+        {
+            // Olympus Makernote
+            // Epson and Agfa use Olypus maker note standard, see:
+            //     http://www.ozhiker.com/electronics/pjmt/jpeg_info/
+            processDirectory(_metadata.getDirectory(OlympusMakernoteDirectory.class), processedDirectoryOffsets, subdirOffset + 8, tiffHeaderOffset);
+        }
+        else if (cameraModel!=null && cameraModel.trim().toUpperCase().startsWith("NIKON"))
+        {
+            if ("Nikon".equals(firstFiveChars))
+            {
+                /* There are two scenarios here:
+                 * Type 1:                  **
+                 * :0000: 4E 69 6B 6F 6E 00 01 00-05 00 02 00 02 00 06 00 Nikon...........
+                 * :0010: 00 00 EC 02 00 00 03 00-03 00 01 00 00 00 06 00 ................
+                 * Type 3:                  **
+                 * :0000: 4E 69 6B 6F 6E 00 02 00-00 00 4D 4D 00 2A 00 00 Nikon....MM.*...
+                 * :0010: 00 08 00 1E 00 01 00 07-00 00 00 04 30 32 30 30 ............0200
+                 */
+                if (_data[subdirOffset+6]==1)
+                    processDirectory(_metadata.getDirectory(NikonType1MakernoteDirectory.class), processedDirectoryOffsets, subdirOffset + 8, tiffHeaderOffset);
+                else if (_data[subdirOffset+6]==2)
+                    processDirectory(_metadata.getDirectory(NikonType2MakernoteDirectory.class), processedDirectoryOffsets, subdirOffset + 18, subdirOffset + 10);
+                else
+                    exifDirectory.addError("Unsupported makernote data ignored.");
+            }
+            else
+            {
+                // The IFD begins with the first MakerNote byte (no ASCII name).  This occurs with CoolPix 775, E990 and D1 models.
+                processDirectory(_metadata.getDirectory(NikonType2MakernoteDirectory.class), processedDirectoryOffsets, subdirOffset, tiffHeaderOffset);
+            }
+        }
+        else if ("SONY CAM".equals(firstEightChars) || "SONY DSC".equals(firstEightChars))
+        {
+            processDirectory(_metadata.getDirectory(SonyMakernoteDirectory.class), processedDirectoryOffsets, subdirOffset + 12, tiffHeaderOffset);
+        }
+        else if ("KDK".equals(firstThreeChars))
+        {
+            processDirectory(_metadata.getDirectory(KodakMakernoteDirectory.class), processedDirectoryOffsets, subdirOffset + 20, tiffHeaderOffset);
+        }
+        else if ("Canon".equalsIgnoreCase(cameraModel))
+        {
+            processDirectory(_metadata.getDirectory(CanonMakernoteDirectory.class), processedDirectoryOffsets, subdirOffset, tiffHeaderOffset);
+        }
+        else if (cameraModel!=null && cameraModel.toUpperCase().startsWith("CASIO"))
+        {
+            if ("QVC\u0000\u0000\u0000".equals(firstSixChars))
+                processDirectory(_metadata.getDirectory(CasioType2MakernoteDirectory.class), processedDirectoryOffsets, subdirOffset + 6, tiffHeaderOffset);
+            else
+                processDirectory(_metadata.getDirectory(CasioType1MakernoteDirectory.class), processedDirectoryOffsets, subdirOffset, tiffHeaderOffset);
+        }
+        else if ("FUJIFILM".equals(firstEightChars) || "Fujifilm".equalsIgnoreCase(cameraModel))
+        {
+            // TODO make this field a passed parameter, to avoid threading issues
+            boolean byteOrderBefore = _isMotorollaByteOrder;
+            // bug in fujifilm makernote ifd means we temporarily use Intel byte ordering
+            _isMotorollaByteOrder = false;
+            // the 4 bytes after "FUJIFILM" in the makernote point to the start of the makernote
+            // IFD, though the offset is relative to the start of the makernote, not the TIFF
+            // header (like everywhere else)
+            int ifdStart = subdirOffset + get32Bits(subdirOffset + 8);
+            processDirectory(_metadata.getDirectory(FujifilmMakernoteDirectory.class), processedDirectoryOffsets, ifdStart, tiffHeaderOffset);
+            _isMotorollaByteOrder = byteOrderBefore;
+        }
+        else if (cameraModel!=null && cameraModel.toUpperCase().startsWith("MINOLTA"))
+        {
+            // Cases seen with the model starting with MINOLTA in capitals seem to have a valid Olympus makernote
+            // area that commences immediately.
+            processDirectory(_metadata.getDirectory(OlympusMakernoteDirectory.class), processedDirectoryOffsets, subdirOffset, tiffHeaderOffset);
+        }
+        else if ("KC".equals(firstTwoChars) || "MINOL".equals(firstFiveChars) || "MLY".equals(firstThreeChars) || "+M+M+M+M".equals(firstEightChars))
+        {
+            // This Konica data is not understood.  Header identified in accordance with information at this site:
+            // http://www.ozhiker.com/electronics/pjmt/jpeg_info/minolta_mn.html
+            // TODO determine how to process the information described at the above website
+            exifDirectory.addError("Unsupported Konica/Minolta data ignored.");
+        }
+        else if ("KYOCERA".equals(firstSevenChars))
+        {
+            // http://www.ozhiker.com/electronics/pjmt/jpeg_info/kyocera_mn.html
+            processDirectory(_metadata.getDirectory(KyoceraMakernoteDirectory.class), processedDirectoryOffsets, subdirOffset + 22, tiffHeaderOffset);
+        }
+        else if ("Panasonic\u0000\u0000\u0000".equals(new String(_data, subdirOffset, 12)))
+        {
+            // NON-Standard TIFF IFD Data using Panasonic Tags. There is no Next-IFD pointer after the IFD
+            // Offsets are relative to the start of the TIFF header at the beginning of the EXIF segment
+            // more information here: http://www.ozhiker.com/electronics/pjmt/jpeg_info/panasonic_mn.html
+            processDirectory(_metadata.getDirectory(PanasonicMakernoteDirectory.class), processedDirectoryOffsets, subdirOffset + 12, tiffHeaderOffset);
+        }
+        else if ("AOC\u0000".equals(firstFourChars))
+        {
+            // NON-Standard TIFF IFD Data using Casio Type 2 Tags
+            // IFD has no Next-IFD pointer at end of IFD, and
+            // Offsets are relative to the start of the current IFD tag, not the TIFF header
+            // Observed for:
+            // - Pentax ist D
+            processDirectory(_metadata.getDirectory(CasioType2MakernoteDirectory.class), processedDirectoryOffsets, subdirOffset + 6, subdirOffset);
+        }
+        else if (cameraModel!=null && (cameraModel.toUpperCase().startsWith("PENTAX") || cameraModel.toUpperCase().startsWith("ASAHI")))
+        {
+            // NON-Standard TIFF IFD Data using Pentax Tags
+            // IFD has no Next-IFD pointer at end of IFD, and
+            // Offsets are relative to the start of the current IFD tag, not the TIFF header
+            // Observed for:
+            // - PENTAX Optio 330
+            // - PENTAX Optio 430
+            processDirectory(_metadata.getDirectory(PentaxMakernoteDirectory.class), processedDirectoryOffsets, subdirOffset, subdirOffset);
+        }
+        else
+        {
+            // TODO how to store makernote data when it's not from a supported camera model?
+            // this is difficult as the starting offset is not known.  we could look for it...
+            exifDirectory.addError("Unsupported makernote data ignored.");
+        }
+    }
+
+    private boolean isDirectoryLengthValid(int dirStartOffset, int tiffHeaderOffset)
+    {
+        int dirTagCount = get16Bits(dirStartOffset);
+        int dirLength = (2 + (12 * dirTagCount) + 4);
+        if (dirLength + dirStartOffset + tiffHeaderOffset>=_data.length) {
+            // Note: Files that had thumbnails trimmed with jhead 1.3 or earlier might trigger this
+            return false;
+        }
+        return true;
+    }
+
+    private void processTag(Directory directory, int tagType, int tagValueOffset, int componentCount, int formatCode)
+    {
+        // Directory simply stores raw values
+        // The display side uses a Descriptor class per directory to turn the raw values into 'pretty' descriptions
+        switch (formatCode)
+        {
+            case FMT_UNDEFINED:
+                // this includes exif user comments
+                final byte[] tagBytes = new byte[componentCount];
+                final int byteCount = componentCount * BYTES_PER_FORMAT[formatCode];
+                for (int i=0; i<byteCount; i++)
+                    tagBytes[i] = _data[tagValueOffset + i];
+                directory.setByteArray(tagType, tagBytes);
+                break;
+            case FMT_STRING:
+                directory.setString(tagType, readString(tagValueOffset, componentCount));
+                break;
+            case FMT_SRATIONAL:
+            case FMT_URATIONAL:
+                if (componentCount==1) {
+                    Rational rational = new Rational(get32Bits(tagValueOffset), get32Bits(tagValueOffset + 4));
+                    directory.setRational(tagType, rational);
+                } else {
+                    Rational[] rationals = new Rational[componentCount];
+                    for (int i = 0; i<componentCount; i++)
+                        rationals[i] = new Rational(get32Bits(tagValueOffset + (8 * i)), get32Bits(tagValueOffset + 4 + (8 * i)));
+                    directory.setRationalArray(tagType, rationals);
+                }
+                break;
+            case FMT_SBYTE:
+            case FMT_BYTE:
+                if (componentCount==1) {
+                    // this may need to be a byte, but I think casting to int is fine
+                    int b = _data[tagValueOffset];
+                    directory.setInt(tagType, b);
+                } else {
+                    int[] bytes = new int[componentCount];
+                    for (int i = 0; i<componentCount; i++)
+                        bytes[i] = _data[tagValueOffset + i];
+                    directory.setIntArray(tagType, bytes);
+                }
+                break;
+            case FMT_SINGLE:
+            case FMT_DOUBLE:
+                if (componentCount==1) {
+                    int i = _data[tagValueOffset];
+                    directory.setInt(tagType, i);
+                } else {
+                    int[] ints = new int[componentCount];
+                    for (int i = 0; i<componentCount; i++)
+                        ints[i] = _data[tagValueOffset + i];
+                    directory.setIntArray(tagType, ints);
+                }
+                break;
+            case FMT_USHORT:
+            case FMT_SSHORT:
+                if (componentCount==1) {
+                    int i = get16Bits(tagValueOffset);
+                    directory.setInt(tagType, i);
+                } else {
+                    int[] ints = new int[componentCount];
+                    for (int i = 0; i<componentCount; i++)
+                        ints[i] = get16Bits(tagValueOffset + (i * 2));
+                    directory.setIntArray(tagType, ints);
+                }
+                break;
+            case FMT_SLONG:
+            case FMT_ULONG:
+                if (componentCount==1) {
+                    int i = get32Bits(tagValueOffset);
+                    directory.setInt(tagType, i);
+                } else {
+                    int[] ints = new int[componentCount];
+                    for (int i = 0; i<componentCount; i++)
+                        ints[i] = get32Bits(tagValueOffset + (i * 4));
+                    directory.setIntArray(tagType, ints);
+                }
+                break;
+            default:
+                directory.addError("Unknown format code " + formatCode + " for tag " + tagType);
+        }
+    }
+
+    private int calculateTagValueOffset(int byteCount, int dirEntryOffset, int tiffHeaderOffset)
+    {
+        if (byteCount>4) {
+            // If its bigger than 4 bytes, the dir entry contains an offset.
+            // dirEntryOffset must be passed, as some makernote implementations (e.g. FujiFilm) incorrectly use an
+            // offset relative to the start of the makernote itself, not the TIFF segment.
+            final int offsetVal = get32Bits(dirEntryOffset + 8);
+            if (offsetVal + byteCount>_data.length) {
+                // Bogus pointer offset and / or bytecount value
+                return -1; // signal error
+            }
+            return tiffHeaderOffset + offsetVal;
+        } else {
+            // 4 bytes or less and value is in the dir entry itself
+            return dirEntryOffset + 8;
+        }
+    }
+
+    /**
+     * Creates a String from the _data buffer starting at the specified offset,
+     * and ending where byte=='\0' or where length==maxLength.
+     */
+    private String readString(int offset, int maxLength)
+    {
+        int length = 0;
+        while ((offset + length)<_data.length && _data[offset + length]!='\0' && length<maxLength)
+            length++;
+
+        return new String(_data, offset, length);
+    }
+
+    /**
+     * Determine the offset at which a given InteropArray entry begins within the specified IFD.
+     * @param dirStartOffset the offset at which the IFD starts
+     * @param entryNumber the zero-based entry number
+     */
+    private int calculateTagOffset(int dirStartOffset, int entryNumber)
+    {
+        // add 2 bytes for the tag count
+        // each entry is 12 bytes, so we skip 12 * the number seen so far
+        return dirStartOffset + 2 + (12 * entryNumber);
+    }
+
+    /**
+     * Get a 16 bit value from file's native byte order.  Between 0x0000 and 0xFFFF.
+     */
+    private int get16Bits(int offset)
+    {
+        if (offset<0 || offset+2>_data.length)
+            throw new ArrayIndexOutOfBoundsException("attempt to read data outside of exif segment (index " + offset + " where max index is " + (_data.length - 1) + ")");
+
+        if (_isMotorollaByteOrder) {
+            // Motorola - MSB first
+            return (_data[offset] << 8 & 0xFF00) | (_data[offset + 1] & 0xFF);
+        } else {
+            // Intel ordering - LSB first
+            return (_data[offset + 1] << 8 & 0xFF00) | (_data[offset] & 0xFF);
+        }
+    }
+
+    /**
+     * Get a 32 bit value from file's native byte order.
+     */
+    private int get32Bits(int offset)
+    {
+        if (offset<0 || offset+4>_data.length)
+            throw new ArrayIndexOutOfBoundsException("attempt to read data outside of exif segment (index " + offset + " where max index is " + (_data.length - 1) + ")");
+
+        if (_isMotorollaByteOrder) {
+            // Motorola - MSB first
+            return (_data[offset] << 24 & 0xFF000000) |
+                    (_data[offset + 1] << 16 & 0xFF0000) |
+                    (_data[offset + 2] << 8 & 0xFF00) |
+                    (_data[offset + 3] & 0xFF);
+        } else {
+            // Intel ordering - LSB first
+            return (_data[offset + 3] << 24 & 0xFF000000) |
+                    (_data[offset + 2] << 16 & 0xFF0000) |
+                    (_data[offset + 1] << 8 & 0xFF00) |
+                    (_data[offset] & 0xFF);
+        }
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/FujifilmMakernoteDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/FujifilmMakernoteDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/FujifilmMakernoteDescriptor.java	(revision 4231)
@@ -0,0 +1,311 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:12:05 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.lang.Rational;
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ * Fujifilm's digicam added the MakerNote tag from the Year2000's model (e.g.Finepix1400,
+ * Finepix4700). It uses IFD format and start from ASCII character 'FUJIFILM', and next 4
+ * bytes(value 0x000c) points the offset to first IFD entry. Example of actual data
+ * structure is shown below.
+ *
+ * :0000: 46 55 4A 49 46 49 4C 4D-0C 00 00 00 0F 00 00 00 :0000: FUJIFILM........
+ * :0010: 07 00 04 00 00 00 30 31-33 30 00 10 02 00 08 00 :0010: ......0130......
+ *
+ * There are two big differences to the other manufacturers.
+ * - Fujifilm's Exif data uses Motorola align, but MakerNote ignores it and uses Intel
+ *   align.
+ * - The other manufacturer's MakerNote counts the "offset to data" from the first byte
+ *   of TIFF header (same as the other IFD), but Fujifilm counts it from the first byte
+ *   of MakerNote itself.
+ */
+public class FujifilmMakernoteDescriptor extends TagDescriptor
+{
+    public FujifilmMakernoteDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType) {
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_SHARPNESS:
+                return getSharpnessDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_WHITE_BALANCE:
+                return getWhiteBalanceDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_COLOR:
+                return getColorDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_TONE:
+                return getToneDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_FLASH_MODE:
+                return getFlashModeDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_FLASH_STRENGTH:
+                return getFlashStrengthDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_MACRO:
+                return getMacroDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_FOCUS_MODE:
+                return getFocusModeDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_SLOW_SYNCHRO:
+                return getSlowSyncDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_PICTURE_MODE:
+                return getPictureModeDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_CONTINUOUS_TAKING_OR_AUTO_BRACKETTING:
+                return getContinuousTakingOrAutoBrackettingDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_BLUR_WARNING:
+                return getBlurWarningDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_FOCUS_WARNING:
+                return getFocusWarningDescription();
+            case FujifilmMakernoteDirectory.TAG_FUJIFILM_AE_WARNING:
+                return getAutoExposureWarningDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getAutoExposureWarningDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_AE_WARNING)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_AE_WARNING);
+        switch (value) {
+            case 0:
+                return "AE good";
+            case 1:
+                return "Over exposed (>1/1000s @ F11)";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFocusWarningDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_FOCUS_WARNING)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_FOCUS_WARNING);
+        switch (value) {
+            case 0:
+                return "Auto focus good";
+            case 1:
+                return "Out of focus";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getBlurWarningDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_BLUR_WARNING)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_BLUR_WARNING);
+        switch (value) {
+            case 0:
+                return "No blur warning";
+            case 1:
+                return "Blur warning";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getContinuousTakingOrAutoBrackettingDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_CONTINUOUS_TAKING_OR_AUTO_BRACKETTING)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_CONTINUOUS_TAKING_OR_AUTO_BRACKETTING);
+        switch (value) {
+            case 0:
+                return "Off";
+            case 1:
+                return "On";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getPictureModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_PICTURE_MODE)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_PICTURE_MODE);
+        switch (value) {
+            case 0:
+                return "Auto";
+            case 1:
+                return "Portrait scene";
+            case 2:
+                return "Landscape scene";
+            case 4:
+                return "Sports scene";
+            case 5:
+                return "Night scene";
+            case 6:
+                return "Program AE";
+            case 256:
+                return "Aperture priority AE";
+            case 512:
+                return "Shutter priority AE";
+            case 768:
+                return "Manual exposure";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getSlowSyncDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_SLOW_SYNCHRO)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_SLOW_SYNCHRO);
+        switch (value) {
+            case 0:
+                return "Off";
+            case 1:
+                return "On";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFocusModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_FOCUS_MODE)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_FOCUS_MODE);
+        switch (value) {
+            case 0:
+                return "Auto focus";
+            case 1:
+                return "Manual focus";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getMacroDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_MACRO)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_MACRO);
+        switch (value) {
+            case 0:
+                return "Off";
+            case 1:
+                return "On";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFlashStrengthDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_FLASH_STRENGTH)) return null;
+        Rational value = _directory.getRational(FujifilmMakernoteDirectory.TAG_FUJIFILM_FLASH_STRENGTH);
+        return value.toSimpleString(false) + " EV (Apex)";
+    }
+
+    public String getFlashModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_FLASH_MODE)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_FLASH_MODE);
+        switch (value) {
+            case 0:
+                return "Auto";
+            case 1:
+                return "On";
+            case 2:
+                return "Off";
+            case 3:
+                return "Red-eye reduction";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getToneDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_TONE)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_TONE);
+        switch (value) {
+            case 0:
+                return "Normal (STD)";
+            case 256:
+                return "High (HARD)";
+            case 512:
+                return "Low (ORG)";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getColorDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_COLOR)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_COLOR);
+        switch (value) {
+            case 0:
+                return "Normal (STD)";
+            case 256:
+                return "High";
+            case 512:
+                return "Low (ORG)";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getWhiteBalanceDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_WHITE_BALANCE)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_WHITE_BALANCE);
+        switch (value) {
+            case 0:
+                return "Auto";
+            case 256:
+                return "Daylight";
+            case 512:
+                return "Cloudy";
+            case 768:
+                return "DaylightColor-fluorescence";
+            case 769:
+                return "DaywhiteColor-fluorescence";
+            case 770:
+                return "White-fluorescence";
+            case 1024:
+                return "Incandenscense";
+            case 3840:
+                return "Custom white balance";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getSharpnessDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(FujifilmMakernoteDirectory.TAG_FUJIFILM_SHARPNESS)) return null;
+        int value = _directory.getInt(FujifilmMakernoteDirectory.TAG_FUJIFILM_SHARPNESS);
+        switch (value) {
+            case 1:
+                return "Softest";
+            case 2:
+                return "Soft";
+            case 3:
+                return "Normal";
+            case 4:
+                return "Hard";
+            case 5:
+                return "Hardest";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/FujifilmMakernoteDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/FujifilmMakernoteDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/FujifilmMakernoteDirectory.java	(revision 4231)
@@ -0,0 +1,85 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:10:47 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ *
+ */
+public class FujifilmMakernoteDirectory extends Directory
+{
+    public static final int TAG_FUJIFILM_MAKERNOTE_VERSION = 0x0000;
+    public static final int TAG_FUJIFILM_QUALITY = 0x1000;
+    public static final int TAG_FUJIFILM_SHARPNESS = 0x1001;
+    public static final int TAG_FUJIFILM_WHITE_BALANCE = 0x1002;
+    public static final int TAG_FUJIFILM_COLOR = 0x1003;
+    public static final int TAG_FUJIFILM_TONE = 0x1004;
+    public static final int TAG_FUJIFILM_FLASH_MODE = 0x1010;
+    public static final int TAG_FUJIFILM_FLASH_STRENGTH = 0x1011;
+    public static final int TAG_FUJIFILM_MACRO = 0x1020;
+    public static final int TAG_FUJIFILM_FOCUS_MODE = 0x1021;
+    public static final int TAG_FUJIFILM_SLOW_SYNCHRO = 0x1030;
+    public static final int TAG_FUJIFILM_PICTURE_MODE = 0x1031;
+    public static final int TAG_FUJIFILM_UNKNOWN_1 = 0x1032;
+    public static final int TAG_FUJIFILM_CONTINUOUS_TAKING_OR_AUTO_BRACKETTING = 0x1100;
+    public static final int TAG_FUJIFILM_UNKNOWN_2 = 0x1200;
+    public static final int TAG_FUJIFILM_BLUR_WARNING = 0x1300;
+    public static final int TAG_FUJIFILM_FOCUS_WARNING = 0x1301;
+    public static final int TAG_FUJIFILM_AE_WARNING = 0x1302;
+
+    protected static final HashMap tagNameMap = new HashMap();
+
+    static
+    {
+        tagNameMap.put(new Integer(TAG_FUJIFILM_AE_WARNING), "AE Warning");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_BLUR_WARNING), "Blur Warning");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_COLOR), "Color");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_CONTINUOUS_TAKING_OR_AUTO_BRACKETTING), "Continuous Taking Or Auto Bracketting");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_FLASH_MODE), "Flash Mode");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_FLASH_STRENGTH), "Flash Strength");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_FOCUS_MODE), "Focus Mode");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_FOCUS_WARNING), "Focus Warning");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_MACRO), "Macro");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_MAKERNOTE_VERSION), "Makernote Version");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_PICTURE_MODE), "Picture Mode");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_QUALITY), "Quality");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_SHARPNESS), "Sharpness");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_SLOW_SYNCHRO), "Slow Synchro");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_TONE), "Tone");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_UNKNOWN_1), "Makernote Unknown 1");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_UNKNOWN_2), "Makernote Unknown 2");
+        tagNameMap.put(new Integer(TAG_FUJIFILM_WHITE_BALANCE), "White Balance");
+    }
+
+    public FujifilmMakernoteDirectory()
+    {
+        this.setDescriptor(new FujifilmMakernoteDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "FujiFilm Makernote";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return tagNameMap;
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/GpsDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/GpsDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/GpsDescriptor.java	(revision 4231)
@@ -0,0 +1,201 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 12-Nov-2002 22:27:52 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.lang.Rational;
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ *
+ */
+public class GpsDescriptor extends TagDescriptor
+{
+    public GpsDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType) {
+            case GpsDirectory.TAG_GPS_ALTITUDE:
+                return getGpsAltitudeDescription();
+            case GpsDirectory.TAG_GPS_ALTITUDE_REF:
+                return getGpsAltitudeRefDescription();
+            case GpsDirectory.TAG_GPS_STATUS:
+                return getGpsStatusDescription();
+            case GpsDirectory.TAG_GPS_MEASURE_MODE:
+                return getGpsMeasureModeDescription();
+            case GpsDirectory.TAG_GPS_SPEED_REF:
+                return getGpsSpeedRefDescription();
+            case GpsDirectory.TAG_GPS_TRACK_REF:
+            case GpsDirectory.TAG_GPS_IMG_DIRECTION_REF:
+            case GpsDirectory.TAG_GPS_DEST_BEARING_REF:
+                return getGpsDirectionReferenceDescription(tagType);
+            case GpsDirectory.TAG_GPS_TRACK:
+            case GpsDirectory.TAG_GPS_IMG_DIRECTION:
+            case GpsDirectory.TAG_GPS_DEST_BEARING:
+                return getGpsDirectionDescription(tagType);
+            case GpsDirectory.TAG_GPS_DEST_DISTANCE_REF:
+                return getGpsDestinationReferenceDescription();
+            case GpsDirectory.TAG_GPS_TIME_STAMP:
+                return getGpsTimeStampDescription();
+                // three rational numbers -- displayed in HH"MM"SS.ss
+            case GpsDirectory.TAG_GPS_LONGITUDE:
+                return getGpsLongitudeDescription();
+            case GpsDirectory.TAG_GPS_LATITUDE:
+                return getGpsLatitudeDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getGpsLatitudeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(GpsDirectory.TAG_GPS_LATITUDE)) return null;
+        return getHoursMinutesSecondsDescription(GpsDirectory.TAG_GPS_LATITUDE);
+    }
+
+    public String getGpsLongitudeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(GpsDirectory.TAG_GPS_LONGITUDE)) return null;
+        return getHoursMinutesSecondsDescription(GpsDirectory.TAG_GPS_LONGITUDE);
+    }
+
+    public String getHoursMinutesSecondsDescription(int tagType) throws MetadataException
+    {
+        Rational[] components = _directory.getRationalArray(tagType);
+        // TODO create an HoursMinutesSecods class ??
+        int deg = components[0].intValue();
+        float min = components[1].floatValue();
+        float sec = components[2].floatValue();
+        // carry fractions of minutes into seconds -- thanks Colin Briton
+        sec += (min % 1) * 60;
+        return String.valueOf(deg) + "\"" + String.valueOf((int)min) + "'" + String.valueOf(sec);
+    }
+
+    public String getGpsTimeStampDescription() throws MetadataException
+    {
+        // time in hour, min, sec
+        if (!_directory.containsTag(GpsDirectory.TAG_GPS_TIME_STAMP)) return null;
+        int[] timeComponents = _directory.getIntArray(GpsDirectory.TAG_GPS_TIME_STAMP);
+        StringBuffer sbuffer = new StringBuffer();
+        sbuffer.append(timeComponents[0]);
+        sbuffer.append(":");
+        sbuffer.append(timeComponents[1]);
+        sbuffer.append(":");
+        sbuffer.append(timeComponents[2]);
+        sbuffer.append(" UTC");
+        return sbuffer.toString();
+    }
+
+    public String getGpsDestinationReferenceDescription()
+    {
+        if (!_directory.containsTag(GpsDirectory.TAG_GPS_DEST_DISTANCE_REF)) return null;
+        String destRef = _directory.getString(GpsDirectory.TAG_GPS_DEST_DISTANCE_REF).trim();
+        if ("K".equalsIgnoreCase(destRef)) {
+            return "kilometers";
+        } else if ("M".equalsIgnoreCase(destRef)) {
+            return "miles";
+        } else if ("N".equalsIgnoreCase(destRef)) {
+            return "knots";
+        } else {
+            return "Unknown (" + destRef + ")";
+        }
+    }
+
+    public String getGpsDirectionDescription(int tagType)
+    {
+        if (!_directory.containsTag(tagType)) return null;
+        String gpsDirection = _directory.getString(tagType).trim();
+        return gpsDirection + " degrees";
+    }
+
+    public String getGpsDirectionReferenceDescription(int tagType)
+    {
+        if (!_directory.containsTag(tagType)) return null;
+        String gpsDistRef = _directory.getString(tagType).trim();
+        if ("T".equalsIgnoreCase(gpsDistRef)) {
+            return "True direction";
+        } else if ("M".equalsIgnoreCase(gpsDistRef)) {
+            return "Magnetic direction";
+        } else {
+            return "Unknown (" + gpsDistRef + ")";
+        }
+    }
+
+    public String getGpsSpeedRefDescription()
+    {
+        if (!_directory.containsTag(GpsDirectory.TAG_GPS_SPEED_REF)) return null;
+        String gpsSpeedRef = _directory.getString(GpsDirectory.TAG_GPS_SPEED_REF).trim();
+        if ("K".equalsIgnoreCase(gpsSpeedRef)) {
+            return "kph";
+        } else if ("M".equalsIgnoreCase(gpsSpeedRef)) {
+            return "mph";
+        } else if ("N".equalsIgnoreCase(gpsSpeedRef)) {
+            return "knots";
+        } else {
+            return "Unknown (" + gpsSpeedRef + ")";
+        }
+    }
+
+    public String getGpsMeasureModeDescription()
+    {
+        if (!_directory.containsTag(GpsDirectory.TAG_GPS_MEASURE_MODE)) return null;
+        String gpsSpeedMeasureMode = _directory.getString(GpsDirectory.TAG_GPS_MEASURE_MODE).trim();
+        if ("2".equalsIgnoreCase(gpsSpeedMeasureMode)) {
+            return "2-dimensional measurement";
+        } else if ("3".equalsIgnoreCase(gpsSpeedMeasureMode)) {
+            return "3-dimensional measurement";
+        } else {
+            return "Unknown (" + gpsSpeedMeasureMode + ")";
+        }
+    }
+
+    public String getGpsStatusDescription()
+    {
+        if (!_directory.containsTag(GpsDirectory.TAG_GPS_STATUS)) return null;
+        String gpsStatus = _directory.getString(GpsDirectory.TAG_GPS_STATUS).trim();
+        if ("A".equalsIgnoreCase(gpsStatus)) {
+            return "Measurement in progess";
+        } else if ("V".equalsIgnoreCase(gpsStatus)) {
+            return "Measurement Interoperability";
+        } else {
+            return "Unknown (" + gpsStatus + ")";
+        }
+    }
+
+    public String getGpsAltitudeRefDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(GpsDirectory.TAG_GPS_ALTITUDE_REF)) return null;
+        int alititudeRef = _directory.getInt(GpsDirectory.TAG_GPS_ALTITUDE_REF);
+        if (alititudeRef == 0) {
+            return "Sea level";
+        } else {
+            return "Unknown (" + alititudeRef + ")";
+        }
+    }
+
+    public String getGpsAltitudeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(GpsDirectory.TAG_GPS_ALTITUDE)) return null;
+        String alititude = _directory.getRational(GpsDirectory.TAG_GPS_ALTITUDE).toSimpleString(true);
+        return alititude + " metres";
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/GpsDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/GpsDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/GpsDirectory.java	(revision 4231)
@@ -0,0 +1,130 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 26-Nov-2002 11:00:52 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ *
+ */
+public class GpsDirectory extends Directory
+{
+    /** GPS tag version GPSVersionID 0 0 BYTE 4 */
+    public static final int TAG_GPS_VERSION_ID = 0x0000;
+    /** North or South Latitude GPSLatitudeRef 1 1 ASCII 2 */
+    public static final int TAG_GPS_LATITUDE_REF = 0x0001;
+    /** Latitude GPSLatitude 2 2 RATIONAL 3 */
+    public static final int TAG_GPS_LATITUDE = 0x0002;
+    /** East or West Longitude GPSLongitudeRef 3 3 ASCII 2 */
+    public static final int TAG_GPS_LONGITUDE_REF = 0x0003;
+    /** Longitude GPSLongitude 4 4 RATIONAL 3 */
+    public static final int TAG_GPS_LONGITUDE = 0x0004;
+    /** Altitude reference GPSAltitudeRef 5 5 BYTE 1 */
+    public static final int TAG_GPS_ALTITUDE_REF = 0x0005;
+    /** Altitude GPSAltitude 6 6 RATIONAL 1 */
+    public static final int TAG_GPS_ALTITUDE = 0x0006;
+    /** GPS time (atomic clock) GPSTimeStamp 7 7 RATIONAL 3 */
+    public static final int TAG_GPS_TIME_STAMP = 0x0007;
+    /** GPS satellites used for measurement GPSSatellites 8 8 ASCII Any */
+    public static final int TAG_GPS_SATELLITES = 0x0008;
+    /** GPS receiver status GPSStatus 9 9 ASCII 2 */
+    public static final int TAG_GPS_STATUS = 0x0009;
+    /** GPS measurement mode GPSMeasureMode 10 A ASCII 2 */
+    public static final int TAG_GPS_MEASURE_MODE = 0x000A;
+    /** Measurement precision GPSDOP 11 B RATIONAL 1 */
+    public static final int TAG_GPS_DOP = 0x000B;
+    /** Speed unit GPSSpeedRef 12 C ASCII 2 */
+    public static final int TAG_GPS_SPEED_REF = 0x000C;
+    /** Speed of GPS receiver GPSSpeed 13 D RATIONAL 1 */
+    public static final int TAG_GPS_SPEED = 0x000D;
+    /** Reference for direction of movement GPSTrackRef 14 E ASCII 2 */
+    public static final int TAG_GPS_TRACK_REF = 0x000E;
+    /** Direction of movement GPSTrack 15 F RATIONAL 1 */
+    public static final int TAG_GPS_TRACK = 0x000F;
+    /** Reference for direction of image GPSImgDirectionRef 16 10 ASCII 2 */
+    public static final int TAG_GPS_IMG_DIRECTION_REF = 0x0010;
+    /** Direction of image GPSImgDirection 17 11 RATIONAL 1 */
+    public static final int TAG_GPS_IMG_DIRECTION = 0x0011;
+    /** Geodetic survey data used GPSMapDatum 18 12 ASCII Any */
+    public static final int TAG_GPS_MAP_DATUM = 0x0012;
+    /** Reference for latitude of destination GPSDestLatitudeRef 19 13 ASCII 2 */
+    public static final int TAG_GPS_DEST_LATITUDE_REF = 0x0013;
+    /** Latitude of destination GPSDestLatitude 20 14 RATIONAL 3 */
+    public static final int TAG_GPS_DEST_LATITUDE = 0x0014;
+    /** Reference for longitude of destination GPSDestLongitudeRef 21 15 ASCII 2 */
+    public static final int TAG_GPS_DEST_LONGITUDE_REF = 0x0015;
+    /** Longitude of destination GPSDestLongitude 22 16 RATIONAL 3 */
+    public static final int TAG_GPS_DEST_LONGITUDE = 0x0016;
+    /** Reference for bearing of destination GPSDestBearingRef 23 17 ASCII 2 */
+    public static final int TAG_GPS_DEST_BEARING_REF = 0x0017;
+    /** Bearing of destination GPSDestBearing 24 18 RATIONAL 1 */
+    public static final int TAG_GPS_DEST_BEARING = 0x0018;
+    /** Reference for distance to destination GPSDestDistanceRef 25 19 ASCII 2 */
+    public static final int TAG_GPS_DEST_DISTANCE_REF = 0x0019;
+    /** Distance to destination GPSDestDistance 26 1A RATIONAL 1 */
+    public static final int TAG_GPS_DEST_DISTANCE = 0x001A;
+
+    protected static final HashMap tagNameMap = new HashMap();
+
+    static
+    {
+        tagNameMap.put(new Integer(TAG_GPS_VERSION_ID), "GPS Version ID");
+        tagNameMap.put(new Integer(TAG_GPS_LATITUDE_REF), "GPS Latitude Ref");
+        tagNameMap.put(new Integer(TAG_GPS_LATITUDE), "GPS Latitude");
+        tagNameMap.put(new Integer(TAG_GPS_LONGITUDE_REF), "GPS Longitude Ref");
+        tagNameMap.put(new Integer(TAG_GPS_LONGITUDE), "GPS Longitude");
+        tagNameMap.put(new Integer(TAG_GPS_ALTITUDE_REF), "GPS Altitude Ref");
+        tagNameMap.put(new Integer(TAG_GPS_ALTITUDE), "GPS Altitude");
+        tagNameMap.put(new Integer(TAG_GPS_TIME_STAMP), "GPS Time-Stamp");
+        tagNameMap.put(new Integer(TAG_GPS_SATELLITES), "GPS Satellites");
+        tagNameMap.put(new Integer(TAG_GPS_STATUS), "GPS Status");
+        tagNameMap.put(new Integer(TAG_GPS_MEASURE_MODE), "GPS Measure Mode");
+        tagNameMap.put(new Integer(TAG_GPS_DOP), "GPS DOP");
+        tagNameMap.put(new Integer(TAG_GPS_SPEED_REF), "GPS Speed Ref");
+        tagNameMap.put(new Integer(TAG_GPS_SPEED), "GPS Speed");
+        tagNameMap.put(new Integer(TAG_GPS_TRACK_REF), "GPS Track Ref");
+        tagNameMap.put(new Integer(TAG_GPS_TRACK), "GPS Track");
+        tagNameMap.put(new Integer(TAG_GPS_IMG_DIRECTION_REF), "GPS Img Direction Ref");
+        tagNameMap.put(new Integer(TAG_GPS_IMG_DIRECTION_REF), "GPS Img Direction");
+        tagNameMap.put(new Integer(TAG_GPS_MAP_DATUM), "GPS Map Datum");
+        tagNameMap.put(new Integer(TAG_GPS_DEST_LATITUDE_REF), "GPS Dest Latitude Ref");
+        tagNameMap.put(new Integer(TAG_GPS_DEST_LATITUDE), "GPS Dest Latitude");
+        tagNameMap.put(new Integer(TAG_GPS_DEST_LONGITUDE_REF), "GPS Dest Longitude Ref");
+        tagNameMap.put(new Integer(TAG_GPS_DEST_LONGITUDE), "GPS Dest Longitude");
+        tagNameMap.put(new Integer(TAG_GPS_DEST_BEARING_REF), "GPS Dest Bearing Ref");
+        tagNameMap.put(new Integer(TAG_GPS_DEST_BEARING), "GPS Dest Bearing");
+        tagNameMap.put(new Integer(TAG_GPS_DEST_DISTANCE_REF), "GPS Dest Distance Ref");
+        tagNameMap.put(new Integer(TAG_GPS_DEST_DISTANCE), "GPS Dest Distance");
+    }
+
+    public GpsDirectory()
+    {
+        this.setDescriptor(new GpsDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "GPS";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return tagNameMap;
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/KodakMakernoteDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/KodakMakernoteDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/KodakMakernoteDescriptor.java	(revision 4231)
@@ -0,0 +1,36 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ * Provides human-readable string versions of the tags stored in a KodakMakernoteDirectory.
+ * Thanks to David Carson for the initial version of this class.
+ */
+public class KodakMakernoteDescriptor extends TagDescriptor
+{
+	public KodakMakernoteDescriptor(Directory directory)
+	{
+		super(directory);
+	}
+	
+	public String getDescription(int tagType) throws MetadataException
+    {
+		return _directory.getString(tagType);
+	}
+}
Index: /trunk/src/com/drew/metadata/exif/KodakMakernoteDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/KodakMakernoteDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/KodakMakernoteDirectory.java	(revision 4231)
@@ -0,0 +1,37 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ * Describes tags specific to Kodak cameras.
+ */
+public class KodakMakernoteDirectory extends Directory
+{
+	protected static final HashMap _tagNameMap = new HashMap();
+	
+	public String getName()
+    {
+		return "Kodak Makernote";
+	}
+
+	protected HashMap getTagNameMap()
+    {
+		return _tagNameMap;
+	}
+}
Index: /trunk/src/com/drew/metadata/exif/KyoceraMakernoteDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/KyoceraMakernoteDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/KyoceraMakernoteDescriptor.java	(revision 4231)
@@ -0,0 +1,63 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ * Provides human-readable string versions of the tags stored in a KyoceraMakernoteDirectory.
+ *
+ * Some information about this makernote taken from here:
+ * http://www.ozhiker.com/electronics/pjmt/jpeg_info/kyocera_mn.html
+ *
+ * Most manufacturer's MakerNote counts the "offset to data" from the first byte
+ * of TIFF header (same as the other IFD), but Kyocera (along with Fujifilm) counts
+ * it from the first byte of MakerNote itself.
+ */
+public class KyoceraMakernoteDescriptor extends TagDescriptor
+{
+    public KyoceraMakernoteDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType) {
+            case KyoceraMakernoteDirectory.TAG_KYOCERA_PRINT_IMAGE_MATCHING_INFO:
+                return getPrintImageMatchingInfoDescription();
+            case KyoceraMakernoteDirectory.TAG_KYOCERA_PROPRIETARY_THUMBNAIL:
+                return getProprietaryThumbnailDataDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getPrintImageMatchingInfoDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(KyoceraMakernoteDirectory.TAG_KYOCERA_PRINT_IMAGE_MATCHING_INFO)) return null;
+        byte[] bytes = _directory.getByteArray(KyoceraMakernoteDirectory.TAG_KYOCERA_PRINT_IMAGE_MATCHING_INFO);
+        return "(" + bytes.length + " bytes)";
+    }
+
+    public String getProprietaryThumbnailDataDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(KyoceraMakernoteDirectory.TAG_KYOCERA_PROPRIETARY_THUMBNAIL)) return null;
+        byte[] bytes = _directory.getByteArray(KyoceraMakernoteDirectory.TAG_KYOCERA_PROPRIETARY_THUMBNAIL);
+        return "(" + bytes.length + " bytes)";
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/KyoceraMakernoteDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/KyoceraMakernoteDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/KyoceraMakernoteDirectory.java	(revision 4231)
@@ -0,0 +1,53 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:10:47 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ *
+ */
+public class KyoceraMakernoteDirectory extends Directory
+{
+    public static final int TAG_KYOCERA_PROPRIETARY_THUMBNAIL = 0x0001;
+    public static final int TAG_KYOCERA_PRINT_IMAGE_MATCHING_INFO = 0x0E00;
+
+    protected static final HashMap tagNameMap = new HashMap();
+
+    static
+    {
+        tagNameMap.put(new Integer(TAG_KYOCERA_PROPRIETARY_THUMBNAIL), "Proprietary Thumbnail Format Data");
+        tagNameMap.put(new Integer(TAG_KYOCERA_PRINT_IMAGE_MATCHING_INFO), "Print Image Matching (PIM) Info");
+    }
+
+    public KyoceraMakernoteDirectory()
+    {
+        this.setDescriptor(new KyoceraMakernoteDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "Kyocera/Contax Makernote";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return tagNameMap;
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/NikonType1MakernoteDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/NikonType1MakernoteDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/NikonType1MakernoteDescriptor.java	(revision 4231)
@@ -0,0 +1,197 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata.exif;
+
+import com.drew.lang.Rational;
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ * Provides human-readable string versions of the tags stored in a NikonType1MakernoteDirectory.
+ * Type-1 is for E-Series cameras prior to (not including) E990.  For example: E700, E800, E900,
+ * E900S, E910, E950.
+ *
+ * MakerNote starts from ASCII string "Nikon". Data format is the same as IFD, but it starts from
+ * offset 0x08. This is the same as Olympus except start string. Example of actual data
+ * structure is shown below.
+ * <pre><code>
+ * :0000: 4E 69 6B 6F 6E 00 01 00-05 00 02 00 02 00 06 00 Nikon...........
+ * :0010: 00 00 EC 02 00 00 03 00-03 00 01 00 00 00 06 00 ................
+ * </code></pre>
+ */
+public class NikonType1MakernoteDescriptor extends TagDescriptor
+{
+    public NikonType1MakernoteDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType) {
+            case NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_QUALITY:
+                return getQualityDescription();
+            case NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_COLOR_MODE:
+                return getColorModeDescription();
+            case NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_IMAGE_ADJUSTMENT:
+                return getImageAdjustmentDescription();
+            case NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_CCD_SENSITIVITY:
+                return getCcdSensitivityDescription();
+            case NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_WHITE_BALANCE:
+                return getWhiteBalanceDescription();
+            case NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_FOCUS:
+                return getFocusDescription();
+            case NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_DIGITAL_ZOOM:
+                return getDigitalZoomDescription();
+            case NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_CONVERTER:
+                return getConverterDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getConverterDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_CONVERTER)) return null;
+        int value = _directory.getInt(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_CONVERTER);
+        switch (value) {
+            case 0:
+                return "None";
+            case 1:
+                return "Fisheye converter";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getDigitalZoomDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_DIGITAL_ZOOM)) return null;
+        Rational value = _directory.getRational(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_DIGITAL_ZOOM);
+        if (value.getNumerator() == 0) {
+            return "No digital zoom";
+        }
+        return value.toSimpleString(true) + "x digital zoom";
+    }
+
+    public String getFocusDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_FOCUS)) return null;
+        Rational value = _directory.getRational(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_FOCUS);
+        if (value.getNumerator() == 1 && value.getDenominator() == 0) {
+            return "Infinite";
+        }
+        return value.toSimpleString(true);
+    }
+
+    public String getWhiteBalanceDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_WHITE_BALANCE)) return null;
+        int value = _directory.getInt(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_WHITE_BALANCE);
+        switch (value) {
+            case 0:
+                return "Auto";
+            case 1:
+                return "Preset";
+            case 2:
+                return "Daylight";
+            case 3:
+                return "Incandescense";
+            case 4:
+                return "Flourescence";
+            case 5:
+                return "Cloudy";
+            case 6:
+                return "SpeedLight";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getCcdSensitivityDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_CCD_SENSITIVITY)) return null;
+        int value = _directory.getInt(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_CCD_SENSITIVITY);
+        switch (value) {
+            case 0:
+                return "ISO80";
+            case 2:
+                return "ISO160";
+            case 4:
+                return "ISO320";
+            case 5:
+                return "ISO100";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getImageAdjustmentDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_IMAGE_ADJUSTMENT)) return null;
+        int value = _directory.getInt(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_IMAGE_ADJUSTMENT);
+        switch (value) {
+            case 0:
+                return "Normal";
+            case 1:
+                return "Bright +";
+            case 2:
+                return "Bright -";
+            case 3:
+                return "Contrast +";
+            case 4:
+                return "Contrast -";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getColorModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_COLOR_MODE)) return null;
+        int value = _directory.getInt(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_COLOR_MODE);
+        switch (value) {
+            case 1:
+                return "Color";
+            case 2:
+                return "Monochrome";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getQualityDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_QUALITY)) return null;
+        int value = _directory.getInt(NikonType1MakernoteDirectory.TAG_NIKON_TYPE1_QUALITY);
+        switch (value) {
+            case 1:
+                return "VGA Basic";
+            case 2:
+                return "VGA Normal";
+            case 3:
+                return "VGA Fine";
+            case 4:
+                return "SXGA Basic";
+            case 5:
+                return "SXGA Normal";
+            case 6:
+                return "SXGA Fine";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/NikonType1MakernoteDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/NikonType1MakernoteDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/NikonType1MakernoteDirectory.java	(revision 4231)
@@ -0,0 +1,80 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:10:47 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ * Contains values specific to Nikon cameras.  Type-1 is for E-Series cameras prior to (not including) E990.
+ *
+ * There are 3 formats of Nikon's MakerNote. MakerNote of E700/E800/E900/E900S/E910/E950
+ * starts from ASCII string "Nikon". Data format is the same as IFD, but it starts from
+ * offset 0x08. This is the same as Olympus except start string. Example of actual data
+ * structure is shown below.
+ * <pre><code>
+ * :0000: 4E 69 6B 6F 6E 00 01 00-05 00 02 00 02 00 06 00 Nikon...........
+ * :0010: 00 00 EC 02 00 00 03 00-03 00 01 00 00 00 06 00 ................
+ * </code></pre>
+ */
+public class NikonType1MakernoteDirectory extends Directory
+{
+    public static final int TAG_NIKON_TYPE1_UNKNOWN_1 = 0x0002;
+    public static final int TAG_NIKON_TYPE1_QUALITY = 0x0003;
+    public static final int TAG_NIKON_TYPE1_COLOR_MODE = 0x0004;
+    public static final int TAG_NIKON_TYPE1_IMAGE_ADJUSTMENT = 0x0005;
+    public static final int TAG_NIKON_TYPE1_CCD_SENSITIVITY = 0x0006;
+    public static final int TAG_NIKON_TYPE1_WHITE_BALANCE = 0x0007;
+    public static final int TAG_NIKON_TYPE1_FOCUS = 0x0008;
+    public static final int TAG_NIKON_TYPE1_UNKNOWN_2 = 0x0009;
+    public static final int TAG_NIKON_TYPE1_DIGITAL_ZOOM = 0x000A;
+    public static final int TAG_NIKON_TYPE1_CONVERTER = 0x000B;
+    public static final int TAG_NIKON_TYPE1_UNKNOWN_3 = 0x0F00;
+
+    protected static final HashMap _tagNameMap = new HashMap();
+
+    static
+    {
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE1_CCD_SENSITIVITY), "CCD Sensitivity");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE1_COLOR_MODE), "Color Mode");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE1_DIGITAL_ZOOM), "Digital Zoom");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE1_CONVERTER), "Fisheye Converter");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE1_FOCUS), "Focus");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE1_IMAGE_ADJUSTMENT), "Image Adjustment");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE1_QUALITY), "Quality");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE1_UNKNOWN_1), "Makernote Unknown 1");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE1_UNKNOWN_2), "Makernote Unknown 2");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE1_UNKNOWN_3), "Makernote Unknown 3");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE1_WHITE_BALANCE), "White Balance");
+    }
+
+    public NikonType1MakernoteDirectory()
+    {
+        this.setDescriptor(new NikonType1MakernoteDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "Nikon Makernote";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return _tagNameMap;
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/NikonType2MakernoteDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/NikonType2MakernoteDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/NikonType2MakernoteDescriptor.java	(revision 4231)
@@ -0,0 +1,169 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata.exif;
+
+import com.drew.lang.Rational;
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+import java.text.DecimalFormat;
+
+/**
+ * Provides human-readable string versions of the tags stored in a NikonType2MakernoteDirectory.
+ * Type-2 applies to the E990 and D-series cameras such as the D1, D70 and D100.
+ */
+public class NikonType2MakernoteDescriptor extends TagDescriptor
+{
+    public NikonType2MakernoteDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    private NikonType2MakernoteDirectory getMakernoteDirectory()
+    {
+        return (NikonType2MakernoteDirectory)_directory;
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType)
+        {
+            case NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_LENS:
+                return getLensDescription();
+            case NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_CAMERA_HUE_ADJUSTMENT:
+                return getHueAdjustmentDescription();
+            case NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_CAMERA_COLOR_MODE:
+                return getColorModeDescription();
+            case NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_AUTO_FLASH_COMPENSATION:
+                return getAutoFlashCompensationDescription();
+            case NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_ISO_1:
+                return getIsoSettingDescription();
+            case NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_DIGITAL_ZOOM:
+                return getDigitalZoomDescription();
+            case NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_AF_FOCUS_POSITION:
+                return getAutoFocusPositionDescription();
+            case NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_FIRMWARE_VERSION:
+                return getAutoFirmwareVersionDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getAutoFocusPositionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_AF_FOCUS_POSITION)) return null;
+        int[] values = _directory.getIntArray(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_AF_FOCUS_POSITION);
+        if (values.length != 4 || values[0] != 0 || values[2] != 0 || values[3] != 0) {
+            return "Unknown (" + _directory.getString(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_AF_FOCUS_POSITION) + ")";
+        }
+        switch (values[1]) {
+            case 0:
+                return "Centre";
+            case 1:
+                return "Top";
+            case 2:
+                return "Bottom";
+            case 3:
+                return "Left";
+            case 4:
+                return "Right";
+            default:
+                return "Unknown (" + values[1] + ")";
+        }
+    }
+
+    public String getDigitalZoomDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_DIGITAL_ZOOM)) return null;
+        Rational rational = _directory.getRational(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_DIGITAL_ZOOM);
+        if (rational.intValue() == 1) {
+            return "No digital zoom";
+        }
+        return rational.toSimpleString(true) + "x digital zoom";
+    }
+
+    public String getIsoSettingDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_ISO_1)) return null;
+        int[] values = _directory.getIntArray(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_ISO_1);
+        if (values[0] != 0 || values[1] == 0) {
+            return "Unknown (" + _directory.getString(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_ISO_1) + ")";
+        }
+        return "ISO " + values[1];
+    }
+
+    public String getAutoFlashCompensationDescription() throws MetadataException
+    {
+        Rational ev = getMakernoteDirectory().getAutoFlashCompensation();
+
+        if (ev==null)
+            return "Unknown";
+
+        DecimalFormat decimalFormat = new DecimalFormat("0.##");
+        return decimalFormat.format(ev.floatValue()) + " EV";
+    }
+
+    public String getLensDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_LENS))
+            return null;
+
+        Rational[] lensValues = _directory.getRationalArray(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_LENS);
+
+        if (lensValues.length!=4)
+            return _directory.getString(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_LENS);
+
+        StringBuffer description = new StringBuffer();
+        description.append(lensValues[0].intValue());
+        description.append('-');
+        description.append(lensValues[1].intValue());
+        description.append("mm f/");
+        description.append(lensValues[2].floatValue());
+        description.append('-');
+        description.append(lensValues[3].floatValue());
+
+        return description.toString();
+    }
+
+    public String getHueAdjustmentDescription()
+    {
+        if (!_directory.containsTag(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_CAMERA_HUE_ADJUSTMENT))
+            return null;
+
+        return _directory.getString(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_CAMERA_HUE_ADJUSTMENT) + " degrees";
+    }
+
+    public String getColorModeDescription()
+    {
+        if (!_directory.containsTag(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_CAMERA_COLOR_MODE))
+            return null;
+
+        String raw = _directory.getString(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_CAMERA_COLOR_MODE);
+        if (raw.startsWith("MODE1"))
+            return "Mode I (sRGB)";
+
+        return raw;
+    }
+
+    public String getAutoFirmwareVersionDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_FIRMWARE_VERSION))
+            return null;
+
+        int[] ints = _directory.getIntArray(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_FIRMWARE_VERSION);
+        return ExifDescriptor.convertBytesToVersionString(ints);
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/NikonType2MakernoteDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/NikonType2MakernoteDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/NikonType2MakernoteDirectory.java	(revision 4231)
@@ -0,0 +1,550 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 3-Oct-2002 10:10:47 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.lang.Rational;
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+
+import java.util.HashMap;
+
+/**
+ * Contains values specific to Nikon cameras.  Type-2 applies to the E990 and D-series cameras such as the E990, D1,
+ * D70 and D100.
+ *
+ * Thanks to Fabrizio Giudici for publishing his reverse-engineering of the D100 makernote data.
+ * http://www.timelesswanderings.net/equipment/D100/NEF.html
+ *
+ * Note that the camera implements image protection (locking images) via the file's 'readonly' attribute.  Similarly
+ * image hiding uses the 'hidden' attribute (observed on the D70).  Consequently, these values are not available here.
+ *
+ * Additional sample images have been observed, and their tag values recorded in javadoc comments for each tag's field.
+ * New tags have subsequently been added since Fabrizio's observations.
+ *
+ * In earlier models (such as the E990 and D1), this directory begins at the first byte of the makernote IFD.  In
+ * later models, the IFD was given the standard prefix to indicate the camera models (most other manufacturers also
+ * provide this prefix to aid in software decoding).
+ */
+public class NikonType2MakernoteDirectory extends Directory
+{
+    /**
+     * Values observed
+     * - 0200 (D70)
+     * - 0200 (D1X)
+     */
+    public static final int TAG_NIKON_TYPE2_FIRMWARE_VERSION = 0x0001;
+
+    /**
+     * Values observed
+     * - 0 250
+     * - 0 400
+     */
+    public static final int TAG_NIKON_TYPE2_ISO_1 = 0x0002;
+
+    /**
+     * Values observed
+     * - COLOR (seen in the D1X)
+     */
+    public static final int TAG_NIKON_TYPE2_COLOR_MODE = 0x0003;
+
+    /**
+     * Values observed
+     * - FILE
+     * - RAW
+     * - NORMAL
+     * - FINE
+     */
+    public static final int TAG_NIKON_TYPE2_QUALITY_AND_FILE_FORMAT = 0x0004;
+
+    /**
+     * The white balance as set in the camera.
+     *
+     * Values observed
+     * - AUTO
+     * - SUNNY (D70)
+     * - FLASH (D1X)
+     * (presumably also SHADOW / INCANDESCENT / FLUORESCENT / CLOUDY)
+     */
+    public static final int TAG_NIKON_TYPE2_CAMERA_WHITE_BALANCE  = 0x0005;
+
+    /**
+     * The sharpening as set in the camera.
+     *
+     * Values observed
+     * - AUTO
+     * - NORMAL (D70)
+     * - NONE (D1X)
+     */
+    public static final int TAG_NIKON_TYPE2_CAMERA_SHARPENING = 0x0006;
+
+    /**
+     * The auto-focus type used by the camera.
+     *
+     * Values observed
+     * - AF-S
+     * - AF-C
+     * - MANUAL
+     */
+    public static final int TAG_NIKON_TYPE2_AF_TYPE = 0x0007;
+
+    /**
+     * Values observed
+     * - NORMAL
+     * - RED-EYE
+     *
+     * Note: when TAG_NIKON_TYPE2_AUTO_FLASH_MODE is blank, Nikon Browser displays "Flash Sync Mode: Not Attached"
+     */
+    public static final int TAG_NIKON_TYPE2_FLASH_SYNC_MODE = 0x0008;
+
+    /**
+     * Values observed
+     * - Built-in,TTL
+     * - Optional,TTL (with speedlight SB800, flash sync mode as NORMAL.  NikonBrowser reports Auto Flash Comp: 0 EV -- which tag is that?) (D70)
+     * - NEW_TTL (Nikon Browser interprets as "D-TTL")
+     * - (blank -- accompanied FlashSyncMode of NORMAL) (D70)
+     */
+    public static final int TAG_NIKON_TYPE2_AUTO_FLASH_MODE = 0x0009;
+
+    /**
+     * Added during merge of Type2 & Type3.  May apply to earlier models, such as E990 and D1.
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_34 = 0x000A;
+
+    /**
+     * Values observed
+     * - 0
+     */
+    public static final int TAG_NIKON_TYPE2_CAMERA_WHITE_BALANCE_FINE = 0x000B;
+
+    /**
+     * The first two numbers are coefficients to multiply red and blue channels according to white balance as set in the
+     * camera. The meaning of the third and the fourth numbers is unknown.
+     *
+     * Values observed
+     * - 2.25882352 1.76078431 0.0 0.0
+     * - 10242/1 34305/1 0/1 0/1
+     * - 234765625/100000000 1140625/1000000 1/1 1/1
+     */
+    public static final int TAG_NIKON_TYPE2_CAMERA_WHITE_BALANCE_RB_COEFF = 0x000C;
+
+    /**
+     * Values observed
+     * - 0,1,6,0 (hex)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_1 = 0x000D;
+
+    /**
+     * Values observed
+     * - î
+
+     * - 0,1,c,0 (hex)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_2 = 0x000E;
+
+    /**
+     * Added during merge of Type2 & Type3.  May apply to earlier models, such as E990 and D1.
+     */
+    public static final int TAG_NIKON_TYPE2_ISO_SELECTION = 0x000F;
+
+    /**
+     * Added during merge of Type2 & Type3.  May apply to earlier models, such as E990 and D1.
+     */
+    public static final int TAG_NIKON_TYPE2_DATA_DUMP = 0x0010;
+
+    /**
+     * Values observed
+     * - 914
+     * - 1379 (D70)
+     * - 2781 (D1X)
+     * - 6942 (D100)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_3 = 0x0011;
+
+    /**
+     * Values observed
+     * - (no value -- blank)
+     */
+    public static final int TAG_NIKON_TYPE2_AUTO_FLASH_COMPENSATION = 0x0012;
+
+    /**
+     * Values observed
+     * - 0 250
+     * - 0 400
+     */
+    public static final int TAG_NIKON_TYPE2_ISO_2 = 0x0013;
+
+    /**
+     * Values observed
+     * - 0 0 49163 53255
+     * - 0 0 3008 2000 (the image dimensions were 3008x2000) (D70)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_21 = 0x0016;
+
+    /**
+     * Values observed
+     * - (blank)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_22 = 0x0017;
+
+    /**
+     * Values observed
+     * - (blank)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_23 = 0x0018;
+
+    /**
+     * Values observed
+     * - 0
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_24 = 0x0019;
+
+    /**
+     * Added during merge of Type2 & Type3.  May apply to earlier models, such as E990 and D1.
+     */
+    public static final int TAG_NIKON_TYPE2_IMAGE_ADJUSTMENT = 0x0080;
+
+    /**
+     * The tone compensation as set in the camera.
+     *
+     * Values observed
+     * - AUTO
+     * - NORMAL (D1X, D100)
+     */
+    public static final int TAG_NIKON_TYPE2_CAMERA_TONE_COMPENSATION = 0x0081;
+
+    /**
+     * Added during merge of Type2 & Type3.  May apply to earlier models, such as E990 and D1.
+     */
+    public static final int TAG_NIKON_TYPE2_ADAPTER = 0x0082;
+
+    /**
+     * Values observed
+     * - 6
+     * - 6 (D70)
+     * - 2 (D1X)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_4 = 0x0083;
+
+    /**
+     * A pair of focal/max-fstop values that describe the lens used.
+     *
+     * Values observed
+     * - 180.0,180.0,2.8,2.8 (D100)
+     * - 240/10 850/10 35/10 45/10
+     * - 18-70mm f/3.5-4.5 (D70)
+     * - 17-35mm f/2.8-2.8 (D1X)
+     * - 70-200mm f/2.8-2.8 (D70)
+     *
+     * Nikon Browser identifies the lens as "18-70mm F/3.5-4.5 G" which
+     * is identical to metadata extractor, except for the "G".  This must
+     * be coming from another tag...
+     */
+    public static final int TAG_NIKON_TYPE2_LENS = 0x0084;
+
+    /**
+     * Added during merge of Type2 & Type3.  May apply to earlier models, such as E990 and D1.
+     */
+    public static final int TAG_NIKON_TYPE2_MANUAL_FOCUS_DISTANCE = 0x0085;
+
+    /**
+     * Added during merge of Type2 & Type3.  May apply to earlier models, such as E990 and D1.
+     */
+    public static final int TAG_NIKON_TYPE2_DIGITAL_ZOOM = 0x0086;
+
+    /**
+     * Values observed
+     * - 0
+     * - 9
+     * - 3 (D1X)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_5 = 0x0087;
+
+    /**
+     * Values observed
+     * -
+     */
+    public static final int TAG_NIKON_TYPE2_AF_FOCUS_POSITION = 0x0088;
+
+    /**
+     * Values observed
+     * - 0
+     * - 1
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_7 = 0x0089;
+
+    /**
+     * Values observed
+     * - 0
+     * - 0
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_20 = 0x008A;
+
+    /**
+     * Values observed
+     * - 48,1,c,0 (hex) (D100)
+     * - @
+
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_8 = 0x008B;
+
+    /**
+     * Unknown.  Fabrizio believes this may be a lookup table for the user-defined curve.
+     *
+     * Values observed
+     * - (blank) (D1X)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_9 = 0x008C;
+
+    /**
+     * The color space as set in the camera.
+     *
+     * Values observed
+     * - MODE1
+     * - Mode I (sRGB) (D70)
+     * - MODE2 (D1X, D100)
+     */
+    public static final int TAG_NIKON_TYPE2_CAMERA_COLOR_MODE = 0x008D;
+
+    /**
+     * Values observed
+     * - NATURAL
+     * - SPEEDLIGHT (D70, D1X)
+     */
+    public static final int TAG_NIKON_TYPE2_LIGHT_SOURCE = 0x0090;
+
+    /**
+     * Values observed
+     * - 0100
+
+)
+     * - 0103 (D70)
+     * - 0100 (D1X)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_11 = 0x0091;
+
+    /**
+     * The hue adjustment as set in the camera.
+     *
+     * Values observed
+     * - 0
+     */
+    public static final int TAG_NIKON_TYPE2_CAMERA_HUE_ADJUSTMENT = 0x0092;
+
+    /**
+     * Values observed
+     * - OFF
+     */
+    public static final int TAG_NIKON_TYPE2_NOISE_REDUCTION = 0x0095;
+
+    /**
+     * Values observed
+     * - 0100
+'~e3
+     * - 0103
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_12 = 0x0097;
+
+    /**
+     * Values observed
+     * - 0100fht@7b,4x,D"Y
+     * - 01015
+     * - 0100w\cH+D$$h$î5
+Q (D1X)
+     * - 30,31,30,30,0,0,b,48,7c,7c,24,24,5,15,24,0,0,0,0,0 (hex) (D100)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_13 = 0x0098;
+
+    /**
+     * Values observed
+     * - 2014 662 (D1X)
+     * - 1517,1012 (D100)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_14 = 0x0099;
+
+    /**
+     * Values observed
+     * - 78/10 78/10
+     * - 78/10 78/10 (D70)
+     * - 59/10 59/5 (D1X)
+     * - 7.8,7.8 (D100)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_15 = 0x009A;
+
+    /**
+     * Values observed
+     * - NO= 00002539
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_25 = 0x00A0;
+
+    /**
+     * Values observed
+     * - 1564851
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_26 = 0x00A2;
+
+    /**
+     * Values observed
+     * - 0
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_27 = 0x00A3;
+
+    /**
+     * This appears to be a sequence number to indentify the exposure.  This value seems to increment
+     * for consecutive exposures (observed on D70).
+     *
+     * Values observed
+     * - 5062
+     */
+    public static final int TAG_NIKON_TYPE2_EXPOSURE_SEQUENCE_NUMBER = 0x00A7;
+
+    /**
+     * Values observed
+     * - 0100 (D70)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_32 = 0x00A8;
+
+    /**
+     * Values observed
+     * - NORMAL (D70)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_33 = 0x00A9;
+
+    /**
+     * Nikon Browser suggests this value represents Saturation...
+     * Values observed
+     * - NORMAL (D70)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_29 = 0x00AA;
+
+    /**
+     * Values observed
+     * - AUTO (D70)
+     * - (blank) (D70)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_30 = 0x00AB;
+
+    /**
+     * Data about changes set by Nikon Capture Editor.
+     *
+     * Values observed
+     */
+    public static final int TAG_NIKON_TYPE2_CAPTURE_EDITOR_DATA = 0x0E01;
+
+    /**
+     * Values observed
+     * - 1473
+     * - 7036 (D100)
+     */
+    public static final int TAG_NIKON_TYPE2_UNKNOWN_16 = 0x0E10;
+
+    protected static final HashMap _tagNameMap = new HashMap();
+
+    static
+    {
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_FIRMWARE_VERSION), "Firmware Version");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_ISO_1), "ISO");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_QUALITY_AND_FILE_FORMAT), "Quality & File Format");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_CAMERA_WHITE_BALANCE), "White Balance");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_CAMERA_SHARPENING), "Sharpening");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_AF_TYPE), "AF Type");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_CAMERA_WHITE_BALANCE_FINE), "White Balance Fine");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_CAMERA_WHITE_BALANCE_RB_COEFF), "White Balance RB Coefficients");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_ISO_2), "ISO");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_ISO_SELECTION), "ISO Selection");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_DATA_DUMP), "Data Dump");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_IMAGE_ADJUSTMENT), "Image Adjustment");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_CAMERA_TONE_COMPENSATION), "Tone Compensation");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_ADAPTER), "Adapter");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_LENS), "Lens");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_MANUAL_FOCUS_DISTANCE), "Manual Focus Distance");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_DIGITAL_ZOOM), "Digital Zoom");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_CAMERA_COLOR_MODE), "Colour Mode");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_CAMERA_HUE_ADJUSTMENT), "Camera Hue Adjustment");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_NOISE_REDUCTION), "Noise Reduction");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_CAPTURE_EDITOR_DATA), "Capture Editor Data");
+
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_1), "Unknown 01");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_2), "Unknown 02");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_3), "Unknown 03");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_4), "Unknown 04");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_5), "Unknown 05");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_AF_FOCUS_POSITION), "AF Focus Position");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_7), "Unknown 07");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_8), "Unknown 08");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_9), "Unknown 09");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_LIGHT_SOURCE), "Light source");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_11), "Unknown 11");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_12), "Unknown 12");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_13), "Unknown 13");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_14), "Unknown 14");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_15), "Unknown 15");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_16), "Unknown 16");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_FLASH_SYNC_MODE), "Flash Sync Mode");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_AUTO_FLASH_MODE), "Auto Flash Mode");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_AUTO_FLASH_COMPENSATION), "Auto Flash Compensation");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_EXPOSURE_SEQUENCE_NUMBER), "Exposure Sequence Number");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_COLOR_MODE), "Color Mode");
+
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_20), "Unknown 20");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_21), "Unknown 21");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_22), "Unknown 22");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_23), "Unknown 23");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_24), "Unknown 24");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_25), "Unknown 25");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_26), "Unknown 26");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_27), "Unknown 27");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_29), "Unknown 29");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_30), "Unknown 30");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_32), "Unknown 32");
+        _tagNameMap.put(new Integer(TAG_NIKON_TYPE2_UNKNOWN_33), "Unknown 33");
+    }
+
+    public NikonType2MakernoteDirectory()
+    {
+        this.setDescriptor(new NikonType2MakernoteDescriptor(this));
+    }
+
+    public Rational getAutoFlashCompensation() throws MetadataException
+    {
+        if (!containsTag(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_AUTO_FLASH_COMPENSATION))
+            return null;
+
+        byte[] bytes = getByteArray(NikonType2MakernoteDirectory.TAG_NIKON_TYPE2_AUTO_FLASH_COMPENSATION);
+        return CalculateFlashCompensationFromBytes(bytes);
+    }
+
+    public static Rational CalculateFlashCompensationFromBytes(byte[] bytes)
+    {
+        if (bytes.length==3)
+        {
+            byte denominator = bytes[2];
+            int numerator = (int)bytes[0] * bytes[1];
+            return new Rational(numerator, denominator);
+        }
+        return null;
+    }
+
+    public String getName()
+    {
+        return "Nikon Makernote";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return _tagNameMap;
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/OlympusMakernoteDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/OlympusMakernoteDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/OlympusMakernoteDescriptor.java	(revision 4231)
@@ -0,0 +1,148 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ * Provides human-readable string versions of the tags stored in an OlympusMakernoteDirectory.
+ */
+public class OlympusMakernoteDescriptor extends TagDescriptor
+{
+    public OlympusMakernoteDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType) {
+            case OlympusMakernoteDirectory.TAG_OLYMPUS_SPECIAL_MODE:
+                return getSpecialModeDescription();
+            case OlympusMakernoteDirectory.TAG_OLYMPUS_JPEG_QUALITY:
+                return getJpegQualityDescription();
+            case OlympusMakernoteDirectory.TAG_OLYMPUS_MACRO_MODE:
+                return getMacroModeDescription();
+            case OlympusMakernoteDirectory.TAG_OLYMPUS_DIGI_ZOOM_RATIO:
+                return getDigiZoomRatioDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getDigiZoomRatioDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(OlympusMakernoteDirectory.TAG_OLYMPUS_DIGI_ZOOM_RATIO)) return null;
+        int value = _directory.getInt(OlympusMakernoteDirectory.TAG_OLYMPUS_DIGI_ZOOM_RATIO);
+        switch (value) {
+            case 0:
+                return "Normal";
+            case 2:
+                return "Digital 2x Zoom";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getMacroModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(OlympusMakernoteDirectory.TAG_OLYMPUS_MACRO_MODE)) return null;
+        int value = _directory.getInt(OlympusMakernoteDirectory.TAG_OLYMPUS_MACRO_MODE);
+        switch (value) {
+            case 0:
+                return "Normal (no macro)";
+            case 1:
+                return "Macro";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getJpegQualityDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(OlympusMakernoteDirectory.TAG_OLYMPUS_JPEG_QUALITY)) return null;
+        int value = _directory.getInt(OlympusMakernoteDirectory.TAG_OLYMPUS_JPEG_QUALITY);
+        switch (value) {
+            case 1:
+                return "SQ";
+            case 2:
+                return "HQ";
+            case 3:
+                return "SHQ";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getSpecialModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(OlympusMakernoteDirectory.TAG_OLYMPUS_SPECIAL_MODE)) return null;
+        int[] values = _directory.getIntArray(OlympusMakernoteDirectory.TAG_OLYMPUS_SPECIAL_MODE);
+        StringBuffer desc = new StringBuffer();
+        switch (values[0]) {
+            case 0:
+                desc.append("Normal picture taking mode");
+                break;
+            case 1:
+                desc.append("Unknown picture taking mode");
+                break;
+            case 2:
+                desc.append("Fast picture taking mode");
+                break;
+            case 3:
+                desc.append("Panorama picture taking mode");
+                break;
+            default:
+                desc.append("Unknown picture taking mode");
+                break;
+        }
+        desc.append(" - ");
+        switch (values[1]) {
+            case 0:
+                desc.append("Unknown sequence number");
+                break;
+            case 1:
+                desc.append("1st in a sequnce");
+                break;
+            case 2:
+                desc.append("2nd in a sequence");
+                break;
+            case 3:
+                desc.append("3rd in a sequence");
+                break;
+            default:
+                desc.append(values[1]);
+                desc.append("th in a sequence");
+                break;
+        }
+        switch (values[2]) {
+            case 1:
+                desc.append("Left to right panorama direction");
+                break;
+            case 2:
+                desc.append("Right to left panorama direction");
+                break;
+            case 3:
+                desc.append("Bottom to top panorama direction");
+                break;
+            case 4:
+                desc.append("Top to bottom panorama direction");
+                break;
+        }
+        return desc.toString();
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/OlympusMakernoteDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/OlympusMakernoteDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/OlympusMakernoteDirectory.java	(revision 4231)
@@ -0,0 +1,360 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:10:47 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ * The Olympus makernote is used by many manufacturers, and as such contains some tags that appear specific to
+ * those manufacturers.  Other users include Konica, Minolta and Epson. 
+ */
+public class OlympusMakernoteDirectory extends Directory
+{
+    /**
+     * Used by Konica / Minolta cameras.
+     */
+    public static final int TAG_OLYMPUS_MAKERNOTE_VERSION = 0x0000;
+
+    /**
+     * Used by Konica / Minolta cameras.
+     */
+    public static final int TAG_OLYMPUS_CAMERA_SETTINGS_1 = 0x0001;
+
+    /**
+     * Alternate Camera Settings Tag. Used by Konica / Minolta cameras.
+     */
+    public static final int TAG_OLYMPUS_CAMERA_SETTINGS_2 = 0x0003;
+
+    /**
+     * Used by Konica / Minolta cameras.
+     */
+    public static final int TAG_OLYMPUS_COMPRESSED_IMAGE_SIZE = 0x0040;
+
+    /**
+     * Used by Konica / Minolta cameras.
+     */
+    public static final int TAG_OLYMPUS_MINOLTA_THUMBNAIL_OFFSET_1 = 0x0081;
+
+    /**
+     * Alternate Thumbnail Offset. Used by Konica / Minolta cameras.
+     */
+    public static final int TAG_OLYMPUS_MINOLTA_THUMBNAIL_OFFSET_2 = 0x0088;
+
+    /**
+     * Length of thumbnail in bytes. Used by Konica / Minolta cameras.
+     */
+    public static final int TAG_OLYMPUS_MINOLTA_THUMBNAIL_LENGTH = 0x0089;
+
+    /**
+     * Used by Konica / Minolta cameras
+     * 0 = Natural Colour
+     * 1 = Black & White
+     * 2 = Vivid colour
+     * 3 = Solarization
+     * 4 = AdobeRGB
+     */
+    public static final int TAG_OLYMPUS_COLOUR_MODE = 0x0101;
+
+    /**
+     * Used by Konica / Minolta cameras.
+     * 0 = Raw
+     * 1 = Super Fine
+     * 2 = Fine
+     * 3 = Standard
+     * 4 = Extra Fine
+     */
+    public static final int TAG_OLYMPUS_IMAGE_QUALITY_1 = 0x0102;
+
+    /**
+     * Not 100% sure about this tag.
+     *
+     * Used by Konica / Minolta cameras.
+     * 0 = Raw
+     * 1 = Super Fine
+     * 2 = Fine
+     * 3 = Standard
+     * 4 = Extra Fine
+     */
+    public static final int TAG_OLYMPUS_IMAGE_QUALITY_2 = 0x0103;
+
+
+    /**
+     * Three values:
+     * Value 1: 0=Normal, 2=Fast, 3=Panorama
+     * Value 2: Sequence Number Value 3:
+     * 1 = Panorama Direction: Left to Right
+     * 2 = Panorama Direction: Right to Left
+     * 3 = Panorama Direction: Bottom to Top
+     * 4 = Panorama Direction: Top to Bottom
+     */
+    public static final int TAG_OLYMPUS_SPECIAL_MODE = 0x0200;
+
+    /**
+     * 1 = Standard Quality
+     * 2 = High Quality
+     * 3 = Super High Quality
+     */
+    public static final int TAG_OLYMPUS_JPEG_QUALITY = 0x0201;
+
+    /**
+     * 0 = Normal (Not Macro)
+     * 1 = Macro
+     */
+    public static final int TAG_OLYMPUS_MACRO_MODE = 0x0202;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_UNKNOWN_1 = 0x0203;
+
+    /**
+     * Zoom Factor (0 or 1 = normal)
+     */
+    public static final int TAG_OLYMPUS_DIGI_ZOOM_RATIO = 0x0204;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_UNKNOWN_2 = 0x0205;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_UNKNOWN_3 = 0x0206;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_FIRMWARE_VERSION = 0x0207;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_PICT_INFO = 0x0208;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_CAMERA_ID = 0x0209;
+
+    /**
+     * Used by Epson cameras
+     * Units = pixels
+     */
+    public static final int TAG_OLYMPUS_IMAGE_WIDTH = 0x020B;
+
+    /**
+     * Used by Epson cameras
+     * Units = pixels
+     */
+    public static final int TAG_OLYMPUS_IMAGE_HEIGHT = 0x020C;
+
+    /**
+     * A string. Used by Epson cameras.
+     */
+    public static final int TAG_OLYMPUS_ORIGINAL_MANUFACTURER_MODEL = 0x020D;
+
+    /**
+     * See the PIM specification here:
+     * http://www.ozhiker.com/electronics/pjmt/jpeg_info/pim.html
+     */
+    public static final int TAG_OLYMPUS_PRINT_IMAGE_MATCHING_INFO = 0x0E00;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_DATA_DUMP = 0x0F00;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_FLASH_MODE = 0x1004;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_BRACKET = 0x1006;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_FOCUS_MODE = 0x100B;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_FOCUS_DISTANCE = 0x100C;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_ZOOM = 0x100D;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_MACRO_FOCUS = 0x100E;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_SHARPNESS = 0x100F;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_COLOUR_MATRIX = 0x1011;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_BLACK_LEVEL = 0x1012;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_WHITE_BALANCE = 0x1015;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_RED_BIAS = 0x1017;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_BLUE_BIAS = 0x1018;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_SERIAL_NUMBER = 0x101A;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_FLASH_BIAS = 0x1023;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_CONTRAST = 0x1029;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_SHARPNESS_FACTOR = 0x102A;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_COLOUR_CONTROL = 0x102B;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_VALID_BITS = 0x102C;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_CORING_FILTER = 0x102D;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_FINAL_WIDTH = 0x102E;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_FINAL_HEIGHT = 0x102F;
+
+    /**
+     *
+     */
+    public static final int TAG_OLYMPUS_COMPRESSION_RATIO = 0x1034;
+
+    protected static final HashMap tagNameMap = new HashMap();
+
+    static
+    {
+        tagNameMap.put(new Integer(TAG_OLYMPUS_SPECIAL_MODE), "Special Mode");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_JPEG_QUALITY), "Jpeg Quality");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_MACRO_MODE), "Macro");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_UNKNOWN_1), "Makernote Unknown 1");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_DIGI_ZOOM_RATIO), "DigiZoom Ratio");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_UNKNOWN_2), "Makernote Unknown 2");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_UNKNOWN_3), "Makernote Unknown 3");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_FIRMWARE_VERSION), "Firmware Version");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_PICT_INFO), "Pict Info");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_CAMERA_ID), "Camera Id");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_DATA_DUMP), "Data Dump");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_MAKERNOTE_VERSION), "Makernote Version");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_CAMERA_SETTINGS_1), "Camera Settings");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_CAMERA_SETTINGS_2), "Camera Settings");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_COMPRESSED_IMAGE_SIZE), "Compressed Image Size");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_MINOLTA_THUMBNAIL_OFFSET_1), "Thumbnail Offset");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_MINOLTA_THUMBNAIL_OFFSET_2), "Thumbnail Offset");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_MINOLTA_THUMBNAIL_LENGTH), "Thumbnail Length");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_COLOUR_MODE), "Colour Mode");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_IMAGE_QUALITY_1), "Image Quality");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_IMAGE_QUALITY_2), "Image Quality");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_IMAGE_HEIGHT), "Image Height");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_ORIGINAL_MANUFACTURER_MODEL), "Original Manufacturer Model");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_PRINT_IMAGE_MATCHING_INFO), "Print Image Matching (PIM) Info");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_FLASH_MODE), "Flash Mode");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_BRACKET), "Bracket");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_FOCUS_MODE), "Focus Mode");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_FOCUS_DISTANCE), "Focus Distance");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_ZOOM), "Zoom");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_MACRO_FOCUS), "Macro Focus");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_SHARPNESS), "Sharpness");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_COLOUR_MATRIX), "Colour Matrix");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_BLACK_LEVEL), "Black Level");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_WHITE_BALANCE), "White Balance");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_RED_BIAS), "Red Bias");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_BLUE_BIAS), "Blue Bias");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_SERIAL_NUMBER), "Serial Number");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_FLASH_BIAS), "Flash Bias");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_CONTRAST), "Contrast");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_SHARPNESS_FACTOR), "Sharpness Factor");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_COLOUR_CONTROL), "Colour Control");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_VALID_BITS), "Valid Bits");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_CORING_FILTER), "Coring Filter");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_FINAL_WIDTH), "Final Width");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_FINAL_HEIGHT), "Final Height");
+        tagNameMap.put(new Integer(TAG_OLYMPUS_COMPRESSION_RATIO), "Compression Ratio");
+    }
+
+    public OlympusMakernoteDirectory()
+    {
+        this.setDescriptor(new OlympusMakernoteDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "Olympus Makernote";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return tagNameMap;
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/PanasonicMakernoteDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/PanasonicMakernoteDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/PanasonicMakernoteDescriptor.java	(revision 4231)
@@ -0,0 +1,85 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ * Provides human-readable string versions of the tags stored in a PanasonicMakernoteDirectory.
+ *
+ * Some information about this makernote taken from here:
+ * http://www.ozhiker.com/electronics/pjmt/jpeg_info/panasonic_mn.html
+ */
+public class PanasonicMakernoteDescriptor extends TagDescriptor
+{
+    public PanasonicMakernoteDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType)
+        {
+            case PanasonicMakernoteDirectory.TAG_PANASONIC_MACRO_MODE:
+                return getMacroModeDescription();
+            case PanasonicMakernoteDirectory.TAG_PANASONIC_RECORD_MODE:
+                return getRecordModeDescription();
+            case PanasonicMakernoteDirectory.TAG_PANASONIC_PRINT_IMAGE_MATCHING_INFO:
+                return getPrintImageMatchingInfoDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getPrintImageMatchingInfoDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PanasonicMakernoteDirectory.TAG_PANASONIC_PRINT_IMAGE_MATCHING_INFO)) return null;
+        byte[] bytes = _directory.getByteArray(PanasonicMakernoteDirectory.TAG_PANASONIC_PRINT_IMAGE_MATCHING_INFO);
+        return "(" + bytes.length + " bytes)";
+    }
+
+    public String getMacroModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PanasonicMakernoteDirectory.TAG_PANASONIC_MACRO_MODE)) return null;
+        int value = _directory.getInt(PanasonicMakernoteDirectory.TAG_PANASONIC_MACRO_MODE);
+        switch (value) {
+            case 1:
+                return "On";
+            case 2:
+                return "Off";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getRecordModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PanasonicMakernoteDirectory.TAG_PANASONIC_RECORD_MODE)) return null;
+        int value = _directory.getInt(PanasonicMakernoteDirectory.TAG_PANASONIC_RECORD_MODE);
+        switch (value) {
+            case 1:
+                return "Normal";
+            case 2:
+                return "Portrait";
+            case 9:
+                return "Macro";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/PanasonicMakernoteDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/PanasonicMakernoteDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/PanasonicMakernoteDirectory.java	(revision 4231)
@@ -0,0 +1,68 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:10:47 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ *
+ */
+public class PanasonicMakernoteDirectory extends Directory
+{
+    public static final int TAG_PANASONIC_QUALITY_MODE = 0x0001;
+    public static final int TAG_PANASONIC_VERSION = 0x0002;
+    /**
+     * 1 = On
+     * 2 = Off
+     */
+    public static final int TAG_PANASONIC_MACRO_MODE = 0x001C;
+    /**
+     * 1 = Normal
+     * 2 = Portrait
+     * 9 = Macro 
+     */
+    public static final int TAG_PANASONIC_RECORD_MODE = 0x001F;
+    public static final int TAG_PANASONIC_PRINT_IMAGE_MATCHING_INFO = 0x0E00;
+
+    protected static final HashMap tagNameMap = new HashMap();
+
+    static
+    {
+        tagNameMap.put(new Integer(TAG_PANASONIC_QUALITY_MODE), "Quality Mode");
+        tagNameMap.put(new Integer(TAG_PANASONIC_VERSION), "Version");
+        tagNameMap.put(new Integer(TAG_PANASONIC_MACRO_MODE), "Macro Mode");
+        tagNameMap.put(new Integer(TAG_PANASONIC_RECORD_MODE), "Record Mode");
+        tagNameMap.put(new Integer(TAG_PANASONIC_PRINT_IMAGE_MATCHING_INFO), "Print Image Matching (PIM) Info");
+    }
+
+    public PanasonicMakernoteDirectory()
+    {
+        this.setDescriptor(new PanasonicMakernoteDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "Panasonic Makernote";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return tagNameMap;
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/PentaxMakernoteDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/PentaxMakernoteDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/PentaxMakernoteDescriptor.java	(revision 4231)
@@ -0,0 +1,248 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ * Provides human-readable string versions of the tags stored in PentaxMakernoteDirectory.
+ *
+ * Some information about this makernote taken from here:
+ * http://www.ozhiker.com/electronics/pjmt/jpeg_info/pentax_mn.html
+ */
+public class PentaxMakernoteDescriptor extends TagDescriptor
+{
+    public PentaxMakernoteDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType) 
+        {
+            case PentaxMakernoteDirectory.TAG_PENTAX_CAPTURE_MODE:
+                return getCaptureModeDescription();
+            case PentaxMakernoteDirectory.TAG_PENTAX_QUALITY_LEVEL:
+                return getQualityLevelDescription();
+            case PentaxMakernoteDirectory.TAG_PENTAX_FOCUS_MODE:
+                return getFocusModeDescription();
+            case PentaxMakernoteDirectory.TAG_PENTAX_FLASH_MODE:
+                return getFlashModeDescription();
+            case PentaxMakernoteDirectory.TAG_PENTAX_WHITE_BALANCE:
+                return getWhiteBalanceDescription();
+            case PentaxMakernoteDirectory.TAG_PENTAX_DIGITAL_ZOOM:
+                return getDigitalZoomDescription();
+            case PentaxMakernoteDirectory.TAG_PENTAX_SHARPNESS:
+                return getSharpnessDescription();
+            case PentaxMakernoteDirectory.TAG_PENTAX_CONTRAST:
+                return getContrastDescription();
+            case PentaxMakernoteDirectory.TAG_PENTAX_SATURATION:
+                return getSaturationDescription();
+            case PentaxMakernoteDirectory.TAG_PENTAX_ISO_SPEED:
+                return getIsoSpeedDescription();
+            case PentaxMakernoteDirectory.TAG_PENTAX_COLOUR:
+                return getColourDescription();
+            default:
+                return _directory.getString(tagType);
+        }
+    }
+
+    public String getColourDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PENTAX_COLOUR)) return null;
+        int value = _directory.getInt(PentaxMakernoteDirectory.TAG_PENTAX_COLOUR);
+        switch (value)
+        {
+            case 1:  return "Normal";
+            case 2:  return "Black & White";
+            case 3:  return "Sepia";
+            default: return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getIsoSpeedDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PENTAX_ISO_SPEED)) return null;
+        int value = _directory.getInt(PentaxMakernoteDirectory.TAG_PENTAX_ISO_SPEED);
+        switch (value)
+        {
+            // TODO there must be other values which aren't catered for here
+            case 10:  return "ISO 100";
+            case 16:  return "ISO 200";
+            case 100: return "ISO 100";
+            case 200: return "ISO 200";
+            default: return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getSaturationDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PENTAX_SATURATION)) return null;
+        int value = _directory.getInt(PentaxMakernoteDirectory.TAG_PENTAX_SATURATION);
+        switch (value)
+        {
+            case 0:  return "Normal";
+            case 1:  return "Low";
+            case 2:  return "High";
+            default: return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getContrastDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PENTAX_CONTRAST)) return null;
+        int value = _directory.getInt(PentaxMakernoteDirectory.TAG_PENTAX_CONTRAST);
+        switch (value)
+        {
+            case 0:  return "Normal";
+            case 1:  return "Low";
+            case 2:  return "High";
+            default: return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getSharpnessDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PENTAX_SHARPNESS)) return null;
+        int value = _directory.getInt(PentaxMakernoteDirectory.TAG_PENTAX_SHARPNESS);
+        switch (value)
+        {
+            case 0:  return "Normal";
+            case 1:  return "Soft";
+            case 2:  return "Hard";
+            default: return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getDigitalZoomDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PENTAX_DIGITAL_ZOOM)) return null;
+        float value = _directory.getFloat(PentaxMakernoteDirectory.TAG_PENTAX_DIGITAL_ZOOM);
+        if (value==0)
+            return "Off";
+        return Float.toString(value);
+    }
+
+    public String getWhiteBalanceDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PENTAX_WHITE_BALANCE)) return null;
+        int value = _directory.getInt(PentaxMakernoteDirectory.TAG_PENTAX_WHITE_BALANCE);
+        switch (value)
+        {
+            case 0:  return "Auto";
+            case 1:  return "Daylight";
+            case 2:  return "Shade";
+            case 3:  return "Tungsten";
+            case 4:  return "Fluorescent";
+            case 5:  return "Manual";
+            default: return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFlashModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PENTAX_FLASH_MODE)) return null;
+        int value = _directory.getInt(PentaxMakernoteDirectory.TAG_PENTAX_FLASH_MODE);
+        switch (value)
+        {
+            case 1:  return "Auto";
+            case 2:  return "Flash On";
+            case 4:  return "Flash Off";
+            case 6:  return "Red-eye Reduction";
+            default: return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getFocusModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PENTAX_FOCUS_MODE)) return null;
+        int value = _directory.getInt(PentaxMakernoteDirectory.TAG_PENTAX_FOCUS_MODE);
+        switch (value)
+        {
+            case 2:  return "Custom";
+            case 3:  return "Auto";
+            default: return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getQualityLevelDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PENTAX_QUALITY_LEVEL)) return null;
+        int value = _directory.getInt(PentaxMakernoteDirectory.TAG_PENTAX_QUALITY_LEVEL);
+        switch (value)
+        {
+            case 0:  return "Good";
+            case 1:  return "Better";
+            case 2:  return "Best";
+            default: return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getCaptureModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PENTAX_CAPTURE_MODE)) return null;
+        int value = _directory.getInt(PentaxMakernoteDirectory.TAG_PENTAX_CAPTURE_MODE);
+        switch (value)
+        {
+            case 1:  return "Auto";
+            case 2:  return "Night-scene";
+            case 3:  return "Manual";
+            case 4:  return "Multiple";
+            default: return "Unknown (" + value + ")";
+        }
+    }
+
+/*
+    public String getPrintImageMatchingInfoDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PANASONIC_PRINT_IMAGE_MATCHING_INFO)) return null;
+        byte[] bytes = _directory.getByteArray(PentaxMakernoteDirectory.TAG_PANASONIC_PRINT_IMAGE_MATCHING_INFO);
+        return "(" + bytes.length + " bytes)";
+    }
+
+    public String getMacroModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PANASONIC_MACRO_MODE)) return null;
+        int value = _directory.getInt(PentaxMakernoteDirectory.TAG_PANASONIC_MACRO_MODE);
+        switch (value) {
+            case 1:
+                return "On";
+            case 2:
+                return "Off";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+
+    public String getRecordModeDescription() throws MetadataException
+    {
+        if (!_directory.containsTag(PentaxMakernoteDirectory.TAG_PANASONIC_RECORD_MODE)) return null;
+        int value = _directory.getInt(PentaxMakernoteDirectory.TAG_PANASONIC_RECORD_MODE);
+        switch (value) {
+            case 1:
+                return "Normal";
+            case 2:
+                return "Portrait";
+            case 9:
+                return "Macro";
+            default:
+                return "Unknown (" + value + ")";
+        }
+    }
+*/
+}
Index: /trunk/src/com/drew/metadata/exif/PentaxMakernoteDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/PentaxMakernoteDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/PentaxMakernoteDirectory.java	(revision 4231)
@@ -0,0 +1,158 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 27-Nov-2002 10:10:47 using IntelliJ IDEA.
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ * Directory for metadata specific to Pentax and Asahi cameras.
+ */
+public class PentaxMakernoteDirectory extends Directory
+{
+    /**
+     * 0 = Auto
+     * 1 = Night-scene
+     * 2 = Manual
+     * 4 = Multiple
+     */
+    public static final int TAG_PENTAX_CAPTURE_MODE = 0x0001;
+
+    /**
+     * 0 = Good
+     * 1 = Better
+     * 2 = Best
+     */
+    public static final int TAG_PENTAX_QUALITY_LEVEL = 0x0002;
+
+    /**
+     * 2 = Custom
+     * 3 = Auto
+     */
+    public static final int TAG_PENTAX_FOCUS_MODE = 0x0003;
+
+    /**
+     * 1 = Auto
+     * 2 = Flash on
+     * 4 = Flash off
+     * 6 = Red-eye Reduction
+     */
+    public static final int TAG_PENTAX_FLASH_MODE = 0x0004;
+
+    /**
+     * 0 = Auto
+     * 1 = Daylight
+     * 2 = Shade
+     * 3 = Tungsten
+     * 4 = Fluorescent
+     * 5 = Manual
+     */
+    public static final int TAG_PENTAX_WHITE_BALANCE = 0x0007;
+
+    /**
+     * (0 = Off)
+     */
+    public static final int TAG_PENTAX_DIGITAL_ZOOM = 0x000A;
+
+    /**
+     * 0 = Normal
+     * 1 = Soft
+     * 2 = Hard
+     */
+    public static final int TAG_PENTAX_SHARPNESS = 0x000B;
+
+    /**
+     * 0 = Normal
+     * 1 = Low
+     * 2 = High
+     */
+    public static final int TAG_PENTAX_CONTRAST = 0x000C;
+
+    /**
+     * 0 = Normal
+     * 1 = Low
+     * 2 = High
+     */
+    public static final int TAG_PENTAX_SATURATION = 0x000D;
+
+    /**
+     * 10 = ISO 100
+     * 16 = ISO 200
+     * 100 = ISO 100
+     * 200 = ISO 200
+     */
+    public static final int TAG_PENTAX_ISO_SPEED = 0x0014;
+
+    /**
+     * 1 = Normal
+     * 2 = Black & White
+     * 3 = Sepia
+     */
+    public static final int TAG_PENTAX_COLOUR = 0x0017;
+
+    /**
+     * See Print Image Matching for specification.
+     * http://www.ozhiker.com/electronics/pjmt/jpeg_info/pim.html
+     */
+    public static final int TAG_PENTAX_PRINT_IMAGE_MATCHING_INFO = 0x0E00;
+
+    /**
+     * (String).
+     */
+    public static final int TAG_PENTAX_TIME_ZONE = 0x1000;
+
+    /**
+     * (String).
+     */
+    public static final int TAG_PENTAX_DAYLIGHT_SAVINGS = 0x1001;
+
+    protected static final HashMap tagNameMap = new HashMap();
+
+    static
+    {
+        tagNameMap.put(new Integer(TAG_PENTAX_CAPTURE_MODE), "Capture Mode");
+        tagNameMap.put(new Integer(TAG_PENTAX_QUALITY_LEVEL), "Quality Level");
+        tagNameMap.put(new Integer(TAG_PENTAX_FOCUS_MODE), "Focus Mode");
+        tagNameMap.put(new Integer(TAG_PENTAX_FLASH_MODE), "Flash Mode");
+        tagNameMap.put(new Integer(TAG_PENTAX_WHITE_BALANCE), "White Balance");
+        tagNameMap.put(new Integer(TAG_PENTAX_DIGITAL_ZOOM), "Digital Zoom");
+        tagNameMap.put(new Integer(TAG_PENTAX_SHARPNESS), "Sharpness");
+        tagNameMap.put(new Integer(TAG_PENTAX_CONTRAST), "Contrast");
+        tagNameMap.put(new Integer(TAG_PENTAX_SATURATION), "Saturation");
+        tagNameMap.put(new Integer(TAG_PENTAX_ISO_SPEED), "ISO Speed");
+        tagNameMap.put(new Integer(TAG_PENTAX_COLOUR), "Colour");
+        tagNameMap.put(new Integer(TAG_PENTAX_PRINT_IMAGE_MATCHING_INFO), "Print Image Matching (PIM) Info");
+        tagNameMap.put(new Integer(TAG_PENTAX_TIME_ZONE), "Time Zone");
+        tagNameMap.put(new Integer(TAG_PENTAX_DAYLIGHT_SAVINGS), "Daylight Savings");
+    }
+
+    public PentaxMakernoteDirectory()
+    {
+        this.setDescriptor(new PentaxMakernoteDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "Pentax Makernote";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return tagNameMap;
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/SonyMakernoteDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/SonyMakernoteDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/SonyMakernoteDescriptor.java	(revision 4231)
@@ -0,0 +1,36 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ * Provides human-readable string versions of the tags stored in a SonyMakernoteDirectory.
+ * Thanks to David Carson for the initial version of this class.
+ */
+public class SonyMakernoteDescriptor extends TagDescriptor
+{
+    public SonyMakernoteDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        return _directory.getString(tagType);
+    }
+}
Index: /trunk/src/com/drew/metadata/exif/SonyMakernoteDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/exif/SonyMakernoteDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/exif/SonyMakernoteDirectory.java	(revision 4231)
@@ -0,0 +1,37 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata.exif;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ * Describes tags specific to Sony cameras.
+ */
+public class SonyMakernoteDirectory extends Directory
+{
+	protected static final HashMap _tagNameMap = new HashMap();
+	
+	public String getName()
+    {
+		return "Sony Makernote";
+	}
+
+	protected HashMap getTagNameMap()
+    {
+		return _tagNameMap;
+	}
+}
Index: /trunk/src/com/drew/metadata/iptc/IptcDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/iptc/IptcDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/iptc/IptcDescriptor.java	(revision 4231)
@@ -0,0 +1,36 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 21-Nov-2002 17:58:19 using IntelliJ IDEA.
+ */
+package com.drew.metadata.iptc;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ *
+ */
+public class IptcDescriptor extends TagDescriptor
+{
+    public IptcDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType)
+    {
+        return _directory.getString(tagType);
+    }
+}
Index: /trunk/src/com/drew/metadata/iptc/IptcDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/iptc/IptcDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/iptc/IptcDirectory.java	(revision 4231)
@@ -0,0 +1,97 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 26-Nov-2002 01:26:39 using IntelliJ IDEA.
+ */
+package com.drew.metadata.iptc;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ *
+ */
+public class IptcDirectory extends Directory
+{
+    public static final int TAG_RECORD_VERSION = 0x0200;
+    public static final int TAG_CAPTION = 0x0278;
+    public static final int TAG_WRITER = 0x027a;
+    public static final int TAG_HEADLINE = 0x0269;
+    public static final int TAG_SPECIAL_INSTRUCTIONS = 0x0228;
+    public static final int TAG_BY_LINE = 0x0250;
+    public static final int TAG_BY_LINE_TITLE = 0x0255;
+    public static final int TAG_CREDIT = 0x026e;
+    public static final int TAG_SOURCE = 0x0273;
+    public static final int TAG_OBJECT_NAME = 0x0205;
+    public static final int TAG_DATE_CREATED = 0x0237;
+    public static final int TAG_CITY = 0x025a;
+    public static final int TAG_PROVINCE_OR_STATE = 0x025f;
+    public static final int TAG_COUNTRY_OR_PRIMARY_LOCATION = 0x0265;
+    public static final int TAG_ORIGINAL_TRANSMISSION_REFERENCE = 0x0267;
+    public static final int TAG_CATEGORY = 0x020f;
+    public static final int TAG_SUPPLEMENTAL_CATEGORIES = 0x0214;
+    public static final int TAG_URGENCY = 0x0200 | 10;
+    public static final int TAG_KEYWORDS = 0x0200 | 25;
+    public static final int TAG_COPYRIGHT_NOTICE = 0x0274;
+    public static final int TAG_RELEASE_DATE = 0x0200 | 30;
+    public static final int TAG_RELEASE_TIME = 0x0200 | 35;
+    public static final int TAG_TIME_CREATED = 0x0200 | 60;
+    public static final int TAG_ORIGINATING_PROGRAM = 0x0200 | 65;
+
+    protected static final HashMap tagNameMap = new HashMap();
+
+    static
+    {
+        tagNameMap.put(new Integer(TAG_RECORD_VERSION), "Directory Version");
+        tagNameMap.put(new Integer(TAG_CAPTION), "Caption/Abstract");
+        tagNameMap.put(new Integer(TAG_WRITER), "Writer/Editor");
+        tagNameMap.put(new Integer(TAG_HEADLINE), "Headline");
+        tagNameMap.put(new Integer(TAG_SPECIAL_INSTRUCTIONS), "Special Instructions");
+        tagNameMap.put(new Integer(TAG_BY_LINE), "By-line");
+        tagNameMap.put(new Integer(TAG_BY_LINE_TITLE), "By-line Title");
+        tagNameMap.put(new Integer(TAG_CREDIT), "Credit");
+        tagNameMap.put(new Integer(TAG_SOURCE), "Source");
+        tagNameMap.put(new Integer(TAG_OBJECT_NAME), "Object Name");
+        tagNameMap.put(new Integer(TAG_DATE_CREATED), "Date Created");
+        tagNameMap.put(new Integer(TAG_CITY), "City");
+        tagNameMap.put(new Integer(TAG_PROVINCE_OR_STATE), "Province/State");
+        tagNameMap.put(new Integer(TAG_COUNTRY_OR_PRIMARY_LOCATION), "Country/Primary Location");
+        tagNameMap.put(new Integer(TAG_ORIGINAL_TRANSMISSION_REFERENCE), "Original Transmission Reference");
+        tagNameMap.put(new Integer(TAG_CATEGORY), "Category");
+        tagNameMap.put(new Integer(TAG_SUPPLEMENTAL_CATEGORIES), "Supplemental Category(s)");
+        tagNameMap.put(new Integer(TAG_URGENCY), "Urgency");
+        tagNameMap.put(new Integer(TAG_KEYWORDS), "Keywords");
+        tagNameMap.put(new Integer(TAG_COPYRIGHT_NOTICE), "Copyright Notice");
+        tagNameMap.put(new Integer(TAG_RELEASE_DATE), "Release Date");
+        tagNameMap.put(new Integer(TAG_RELEASE_TIME), "Release Time");
+        tagNameMap.put(new Integer(TAG_TIME_CREATED), "Time Created");
+        tagNameMap.put(new Integer(TAG_ORIGINATING_PROGRAM), "Originating Program");
+    }
+
+    public IptcDirectory()
+    {
+        this.setDescriptor(new IptcDescriptor(this));
+    }
+
+    public String getName()
+    {
+        return "Iptc";
+    }
+
+    protected HashMap getTagNameMap()
+    {
+        return tagNameMap;
+    }
+}
Index: /trunk/src/com/drew/metadata/iptc/IptcProcessingException.java
===================================================================
--- /trunk/src/com/drew/metadata/iptc/IptcProcessingException.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/iptc/IptcProcessingException.java	(revision 4231)
@@ -0,0 +1,51 @@
+/*
+ * ExifProcessingException.java
+ *
+ * This class is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created on 29 April 2002, 00:33
+ */
+
+package com.drew.metadata.iptc;
+
+import com.drew.metadata.MetadataException;
+
+/**
+ * The exception type raised during reading of Iptc data in the instance of
+ * unexpected data conditions.
+ * @author  Drew Noakes http://drewnoakes.com
+ */
+public class IptcProcessingException extends MetadataException
+{
+    /**
+     * Constructs an instance of <code>ExifProcessingException</code> with the
+     * specified detail message.
+     * @param message the detail message
+     */
+    public IptcProcessingException(String message)
+    {
+        super(message);
+    }
+
+    /**
+     * Constructs an instance of <code>IptcProcessingException</code> with the
+     * specified detail message and inner exception.
+     * @param message the detail message
+     * @param cause an inner exception
+     */
+    public IptcProcessingException(String message, Throwable cause)
+    {
+        super(message, cause);
+    }
+}
Index: /trunk/src/com/drew/metadata/iptc/IptcReader.java
===================================================================
--- /trunk/src/com/drew/metadata/iptc/IptcReader.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/iptc/IptcReader.java	(revision 4231)
@@ -0,0 +1,225 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on 12-Nov-2002 19:00:03 using IntelliJ IDEA.
+ */
+package com.drew.metadata.iptc;
+
+import com.drew.imaging.jpeg.JpegProcessingException;
+import com.drew.imaging.jpeg.JpegSegmentReader;
+import com.drew.metadata.Directory;
+import com.drew.metadata.Metadata;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.MetadataReader;
+
+import java.io.File;
+import java.io.InputStream;
+import java.util.Date;
+
+/**
+ *
+ */
+public class IptcReader implements MetadataReader
+{
+/*
+    public static final int DIRECTORY_IPTC = 2;
+
+    public static final int ENVELOPE_RECORD = 1;
+    public static final int APPLICATION_RECORD_2 = 2;
+    public static final int APPLICATION_RECORD_3 = 3;
+    public static final int APPLICATION_RECORD_4 = 4;
+    public static final int APPLICATION_RECORD_5 = 5;
+    public static final int APPLICATION_RECORD_6 = 6;
+    public static final int PRE_DATA_RECORD = 7;
+    public static final int DATA_RECORD = 8;
+    public static final int POST_DATA_RECORD = 9;
+*/
+    /**
+     * The Iptc data segment.
+     */
+    private final byte[] _data;
+
+    /**
+     * Creates a new IptcReader for the specified Jpeg jpegFile.
+     */
+    public IptcReader(File jpegFile) throws JpegProcessingException
+    {
+        this(new JpegSegmentReader(jpegFile).readSegment(JpegSegmentReader.SEGMENT_APPD));
+    }
+
+    /** Creates an IptcReader for a JPEG stream.
+     *
+     * @param is JPEG stream. Stream will be closed.
+     */
+    public IptcReader(InputStream is) throws JpegProcessingException
+    {
+        this(new JpegSegmentReader(is).readSegment(JpegSegmentReader.SEGMENT_APPD));
+    }
+
+    public IptcReader(byte[] data)
+    {
+        _data = data;
+    }
+
+    /**
+     * Performs the Exif data extraction, returning a new instance of <code>Metadata</code>.
+     */
+    public Metadata extract()
+    {
+        return extract(new Metadata());
+    }
+
+    /**
+     * Performs the Exif data extraction, adding found values to the specified
+     * instance of <code>Metadata</code>.
+     */
+    public Metadata extract(Metadata metadata)
+    {
+        if (_data == null) {
+            return metadata;
+        }
+
+        Directory directory = metadata.getDirectory(IptcDirectory.class);
+
+        // find start of data
+        int offset = 0;
+        try {
+            while (offset < _data.length - 1 && get32Bits(offset) != 0x1c02) {
+                offset++;
+            }
+        } catch (MetadataException e) {
+            directory.addError("Couldn't find start of Iptc data (invalid segment)");
+            return metadata;
+        }
+
+        // for each tag
+        while (offset < _data.length) {
+            // identifies start of a tag
+            if (_data[offset] != 0x1c) {
+                break;
+            }
+            // we need at least five bytes left to read a tag
+            if ((offset + 5) >= _data.length) {
+                break;
+            }
+
+            offset++;
+
+            int directoryType;
+            int tagType;
+            int tagByteCount;
+            try {
+                directoryType = _data[offset++];
+                tagType = _data[offset++];
+                tagByteCount = get32Bits(offset);
+            } catch (MetadataException e) {
+                directory.addError("Iptc data segment ended mid-way through tag descriptor");
+                return metadata;
+            }
+            offset += 2;
+            if ((offset + tagByteCount) > _data.length) {
+                directory.addError("data for tag extends beyond end of iptc segment");
+                break;
+            }
+
+            processTag(directory, directoryType, tagType, offset, tagByteCount);
+            offset += tagByteCount;
+        }
+
+        return metadata;
+    }
+
+    /**
+     * Returns an int calculated from two bytes of data at the specified offset (MSB, LSB).
+     * @param offset position within the data buffer to read first byte
+     * @return the 32 bit int value, between 0x0000 and 0xFFFF
+     */
+    private int get32Bits(int offset) throws MetadataException
+    {
+        if (offset >= _data.length) {
+            throw new MetadataException("Attempt to read bytes from outside Iptc data buffer");
+        }
+        return ((_data[offset] & 255) << 8) | (_data[offset + 1] & 255);
+    }
+
+    /**
+     * This method serves as marsheller of objects for dataset. It converts from IPTC
+     * octets to relevant java object.
+     */
+    private void processTag(Directory directory, int directoryType, int tagType, int offset, int tagByteCount)
+    {
+        int tagIdentifier = tagType | (directoryType << 8);
+
+        switch (tagIdentifier) {
+            case IptcDirectory.TAG_RECORD_VERSION:
+                // short
+                short shortValue = (short)((_data[offset] << 8) | _data[offset + 1]);
+                directory.setInt(tagIdentifier, shortValue);
+                return;
+            case IptcDirectory.TAG_URGENCY:
+                // byte
+                directory.setInt(tagIdentifier, _data[offset]);
+                return;
+            case IptcDirectory.TAG_RELEASE_DATE:
+            case IptcDirectory.TAG_DATE_CREATED:
+                // Date object
+                if (tagByteCount >= 8) {
+                    String dateStr = new String(_data, offset, tagByteCount);
+                    try {
+                        int year = Integer.parseInt(dateStr.substring(0, 4));
+                        int month = Integer.parseInt(dateStr.substring(4, 6)) - 1;
+                        int day = Integer.parseInt(dateStr.substring(6, 8));
+                        Date date = (new java.util.GregorianCalendar(year, month, day)).getTime();
+                        directory.setDate(tagIdentifier, date);
+                        return;
+                    } catch (NumberFormatException e) {
+                        // fall through and we'll store whatever was there as a String
+                    }
+                }
+            case IptcDirectory.TAG_RELEASE_TIME:
+            case IptcDirectory.TAG_TIME_CREATED:
+                // time...
+            default:
+                // fall through
+        }
+        // If no special handling by now, treat it as a string
+        String str;
+        if (tagByteCount < 1) {
+            str = "";
+        } else {
+            str = new String(_data, offset, tagByteCount);
+        }
+        if (directory.containsTag(tagIdentifier)) {
+            String[] oldStrings;
+            String[] newStrings;
+            try {
+                oldStrings = directory.getStringArray(tagIdentifier);
+            } catch (MetadataException e) {
+                oldStrings = null;
+            }
+            if (oldStrings == null) {
+                newStrings = new String[1];
+            } else {
+                newStrings = new String[oldStrings.length + 1];
+                for (int i = 0; i < oldStrings.length; i++) {
+                    newStrings[i] = oldStrings[i];
+                }
+            }
+            newStrings[newStrings.length - 1] = str;
+            directory.setStringArray(tagIdentifier, newStrings);
+        } else {
+            directory.setString(tagIdentifier, str);
+        }
+    }
+}
Index: /trunk/src/com/drew/metadata/jpeg/JpegCommentDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/jpeg/JpegCommentDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/jpeg/JpegCommentDescriptor.java	(revision 4231)
@@ -0,0 +1,37 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on Oct 10, 2003 using IntelliJ IDEA.
+ */
+package com.drew.metadata.jpeg;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ *
+ * @author Drew Noakes http://drewnoakes.com
+ */
+public class JpegCommentDescriptor extends TagDescriptor
+{
+    public JpegCommentDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType)
+    {
+        return _directory.getString(tagType);
+    }
+}
Index: /trunk/src/com/drew/metadata/jpeg/JpegCommentDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/jpeg/JpegCommentDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/jpeg/JpegCommentDirectory.java	(revision 4231)
@@ -0,0 +1,49 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on Oct 10, 2003 using IntelliJ IDEA.
+ */
+package com.drew.metadata.jpeg;
+
+import com.drew.metadata.Directory;
+
+import java.util.HashMap;
+
+/**
+ *
+ * @author Drew Noakes http://drewnoakes.com
+ */
+public class JpegCommentDirectory extends Directory {
+
+	/** This is in bits/sample, usually 8 (12 and 16 not supported by most software). */
+	public static final int TAG_JPEG_COMMENT = 0;
+
+	protected static final HashMap tagNameMap = new HashMap();
+
+	static {
+        tagNameMap.put(new Integer(TAG_JPEG_COMMENT), "Jpeg Comment");
+	}
+
+    public JpegCommentDirectory() {
+		this.setDescriptor(new JpegCommentDescriptor(this));
+	}
+
+	public String getName() {
+		return "JpegComment";
+	}
+
+	protected HashMap getTagNameMap() {
+		return tagNameMap;
+	}
+}
Index: /trunk/src/com/drew/metadata/jpeg/JpegCommentReader.java
===================================================================
--- /trunk/src/com/drew/metadata/jpeg/JpegCommentReader.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/jpeg/JpegCommentReader.java	(revision 4231)
@@ -0,0 +1,84 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on Oct 10, 2003 using IntelliJ IDEA.
+ */
+package com.drew.metadata.jpeg;
+
+import com.drew.imaging.jpeg.JpegProcessingException;
+import com.drew.imaging.jpeg.JpegSegmentReader;
+import com.drew.metadata.Metadata;
+import com.drew.metadata.MetadataReader;
+
+import java.io.File;
+import java.io.InputStream;
+
+/**
+ *
+ * @author Drew Noakes http://drewnoakes.com
+ */
+public class JpegCommentReader implements MetadataReader
+{
+    /**
+     * The COM data segment.
+     */
+    private final byte[] _data;
+
+    /**
+     * Creates a new JpegReader for the specified Jpeg jpegFile.
+     */
+    public JpegCommentReader(File jpegFile) throws JpegProcessingException
+    {
+        this(new JpegSegmentReader(jpegFile).readSegment(JpegSegmentReader.SEGMENT_COM));
+    }
+
+    /** Creates a JpegCommentReader for a JPEG stream.
+     *
+     * @param is JPEG stream. Stream will be closed.
+     */
+    public JpegCommentReader(InputStream is) throws JpegProcessingException
+    {
+        this(new JpegSegmentReader(is).readSegment(JpegSegmentReader.SEGMENT_APPD));
+    }
+
+    public JpegCommentReader(byte[] data)
+    {
+        _data = data;
+    }
+
+    /**
+     * Performs the Jpeg data extraction, returning a new instance of <code>Metadata</code>.
+     */
+    public Metadata extract()
+    {
+        return extract(new Metadata());
+    }
+
+    /**
+     * Performs the Jpeg data extraction, adding found values to the specified
+     * instance of <code>Metadata</code>.
+     */
+    public Metadata extract(Metadata metadata)
+    {
+        if (_data==null) {
+            return metadata;
+        }
+
+        JpegCommentDirectory directory = (JpegCommentDirectory)metadata.getDirectory(JpegCommentDirectory.class);
+
+        directory.setString(JpegCommentDirectory.TAG_JPEG_COMMENT, new String(_data));
+
+        return metadata;
+    }
+}
Index: /trunk/src/com/drew/metadata/jpeg/JpegComponent.java
===================================================================
--- /trunk/src/com/drew/metadata/jpeg/JpegComponent.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/jpeg/JpegComponent.java	(revision 4231)
@@ -0,0 +1,81 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on Oct 9, 17:04:07 using IntelliJ IDEA.
+ */
+package com.drew.metadata.jpeg;
+
+import com.drew.metadata.MetadataException;
+
+import java.io.Serializable;
+
+/**
+ * Created by IntelliJ IDEA.
+ * User: dnoakes
+ * Date: 09-Oct-2003
+ * Time: 17:04:07
+ * To change this template use Options | File Templates.
+ */
+public class JpegComponent implements Serializable
+{
+    private final int _componentId;
+    private final int _samplingFactorByte;
+    private final int _quantizationTableNumber;
+
+    public JpegComponent(int componentId, int samplingFactorByte, int quantizationTableNumber)
+    {
+        _componentId = componentId;
+        _samplingFactorByte = samplingFactorByte;
+        _quantizationTableNumber = quantizationTableNumber;
+    }
+
+    public int getComponentId()
+    {
+        return _componentId;
+    }
+
+    public String getComponentName() throws MetadataException
+    {
+        switch (_componentId)
+        {
+            case 1:
+                return "Y";
+            case 2:
+                return "Cb";
+            case 3:
+                return "Cr";
+            case 4:
+                return "I";
+            case 5:
+                return "Q";
+        }
+
+        throw new MetadataException("Unsupported component id: " + _componentId);
+    }
+
+    public int getQuantizationTableNumber()
+    {
+        return _quantizationTableNumber;
+    }
+
+    public int getHorizontalSamplingFactor()
+    {
+        return _samplingFactorByte & 0x0F;
+    }
+
+    public int getVerticalSamplingFactor()
+    {
+        return (_samplingFactorByte>>4) & 0x0F;
+    }
+}
Index: /trunk/src/com/drew/metadata/jpeg/JpegDescriptor.java
===================================================================
--- /trunk/src/com/drew/metadata/jpeg/JpegDescriptor.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/jpeg/JpegDescriptor.java	(revision 4231)
@@ -0,0 +1,88 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ */
+package com.drew.metadata.jpeg;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.TagDescriptor;
+
+/**
+ * Provides human-readable string versions of the tags stored in a JpegDirectory.
+ * Thanks to Darrell Silver (www.darrellsilver.com) for the initial version of this class.
+ */
+public class JpegDescriptor extends TagDescriptor
+{
+    public JpegDescriptor(Directory directory)
+    {
+        super(directory);
+    }
+
+    public String getDescription(int tagType) throws MetadataException
+    {
+        switch (tagType)
+        {
+            case JpegDirectory.TAG_JPEG_COMPONENT_DATA_1:
+                return getComponentDataDescription(0);
+            case JpegDirectory.TAG_JPEG_COMPONENT_DATA_2:
+                return getComponentDataDescription(1);
+            case JpegDirectory.TAG_JPEG_COMPONENT_DATA_3:
+                return getComponentDataDescription(2);
+            case JpegDirectory.TAG_JPEG_COMPONENT_DATA_4:
+                return getComponentDataDescription(3);
+            case JpegDirectory.TAG_JPEG_DATA_PRECISION:
+                return getDataPrecisionDescription();
+            case JpegDirectory.TAG_JPEG_IMAGE_HEIGHT:
+                return getImageHeightDescription();
+            case JpegDirectory.TAG_JPEG_IMAGE_WIDTH:
+                return getImageWidthDescription();
+        }
+
+        return _directory.getString(tagType);
+    }
+
+    public String getImageWidthDescription()
+    {
+        return _directory.getString(JpegDirectory.TAG_JPEG_IMAGE_WIDTH) + " pixels";
+    }
+
+    public String getImageHeightDescription()
+    {
+        return _directory.getString(JpegDirectory.TAG_JPEG_IMAGE_HEIGHT) + " pixels";
+    }
+
+    public String getDataPrecisionDescription()
+    {
+        return _directory.getString(JpegDirectory.TAG_JPEG_DATA_PRECISION) + " bits";
+    }
+
+    public String getComponentDataDescription(int componentNumber) throws MetadataException
+    {
+        JpegComponent component = ((JpegDirectory)_directory).getComponent(componentNumber);
+
+        if (component==null)
+            throw new MetadataException("No Jpeg component exists with number " + componentNumber);
+
+        StringBuffer sb = new StringBuffer();
+        sb.append(component.getComponentName());
+        sb.append(" component: Quantization table ");
+        sb.append(component.getQuantizationTableNumber());
+        sb.append(", Sampling factors ");
+        sb.append(component.getHorizontalSamplingFactor());
+        sb.append(" horiz/");
+        sb.append(component.getVerticalSamplingFactor());
+        sb.append(" vert");
+        return sb.toString();
+    }
+}
Index: /trunk/src/com/drew/metadata/jpeg/JpegDirectory.java
===================================================================
--- /trunk/src/com/drew/metadata/jpeg/JpegDirectory.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/jpeg/JpegDirectory.java	(revision 4231)
@@ -0,0 +1,111 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created on Aug 2, 2003.
+ */
+package com.drew.metadata.jpeg;
+
+import com.drew.metadata.Directory;
+import com.drew.metadata.MetadataException;
+
+import java.util.HashMap;
+
+/**
+ * Directory of tags and values for the SOF0 Jpeg segment.  This segment holds basic metadata about the image.
+ * @author Darrell Silver http://www.darrellsilver.com and Drew Noakes
+ */
+public class JpegDirectory extends Directory {
+
+	/** This is in bits/sample, usually 8 (12 and 16 not supported by most software). */
+	public static final int TAG_JPEG_DATA_PRECISION = 0;
+	/** The image's height.  Necessary for decoding the image, so it should always be there. */
+	public static final int TAG_JPEG_IMAGE_HEIGHT = 1;
+	/** The image's width.  Necessary for decoding the image, so it should always be there. */
+	public static final int TAG_JPEG_IMAGE_WIDTH = 3;
+	/** Usually 1 = grey scaled, 3 = color YcbCr or YIQ, 4 = color CMYK
+	 * Each component TAG_COMPONENT_DATA_[1-4], has the following meaning:
+	 * component Id(1byte)(1 = Y, 2 = Cb, 3 = Cr, 4 = I, 5 = Q),
+	 * sampling factors (1byte) (bit 0-3 vertical., 4-7 horizontal.),
+	 * quantization table number (1 byte).
+	 * <p>
+	 * This info is from http://www.funducode.com/freec/Fileformats/format3/format3b.htm
+	 */
+	public static final int TAG_JPEG_NUMBER_OF_COMPONENTS = 5;
+
+    // NOTE!  Component tag type int values must increment in steps of 1
+
+	/** the first of a possible 4 color components.  Number of components specified in TAG_JPEG_NUMBER_OF_COMPONENTS.*/
+	public static final int TAG_JPEG_COMPONENT_DATA_1 = 6;
+	/** the second of a possible 4 color components.  Number of components specified in TAG_JPEG_NUMBER_OF_COMPONENTS.*/
+	public static final int TAG_JPEG_COMPONENT_DATA_2 = 7;
+	/** the third of a possible 4 color components.  Number of components specified in TAG_JPEG_NUMBER_OF_COMPONENTS.*/
+	public static final int TAG_JPEG_COMPONENT_DATA_3 = 8;
+	/** the fourth of a possible 4 color components.  Number of components specified in TAG_JPEG_NUMBER_OF_COMPONENTS.*/
+	public static final int TAG_JPEG_COMPONENT_DATA_4 = 9;
+
+	protected static final HashMap tagNameMap = new HashMap();
+
+	static {
+        tagNameMap.put(new Integer(TAG_JPEG_DATA_PRECISION), "Data Precision");
+        tagNameMap.put(new Integer(TAG_JPEG_IMAGE_WIDTH), "Image Width");
+        tagNameMap.put(new Integer(TAG_JPEG_IMAGE_HEIGHT), "Image Height");
+		tagNameMap.put(new Integer(TAG_JPEG_NUMBER_OF_COMPONENTS), "Number of Components");
+		tagNameMap.put(new Integer(TAG_JPEG_COMPONENT_DATA_1), "Component 1");
+		tagNameMap.put(new Integer(TAG_JPEG_COMPONENT_DATA_2), "Component 2");
+		tagNameMap.put(new Integer(TAG_JPEG_COMPONENT_DATA_3), "Component 3");
+		tagNameMap.put(new Integer(TAG_JPEG_COMPONENT_DATA_4), "Component 4");
+	}
+
+    public JpegDirectory() {
+		this.setDescriptor(new JpegDescriptor(this));
+	}
+
+	public String getName() {
+		return "Jpeg";
+	}
+
+	protected HashMap getTagNameMap() {
+		return tagNameMap;
+	}
+
+    /**
+     *
+     * @param componentNumber The zero-based index of the component.  This number is normally between 0 and 3.
+     *        Use getNumberOfComponents for bounds-checking.
+     * @return
+     */
+    public JpegComponent getComponent(int componentNumber)
+    {
+        int tagType = JpegDirectory.TAG_JPEG_COMPONENT_DATA_1 + componentNumber;
+
+        JpegComponent component = (JpegComponent)getObject(tagType);
+
+        return component;
+    }
+
+    public int getImageWidth() throws MetadataException
+    {
+        return getInt(JpegDirectory.TAG_JPEG_IMAGE_WIDTH);
+    }
+
+    public int getImageHeight() throws MetadataException
+    {
+        return getInt(JpegDirectory.TAG_JPEG_IMAGE_HEIGHT);
+    }
+
+    public int getNumberOfComponents() throws MetadataException
+    {
+        return getInt(JpegDirectory.TAG_JPEG_NUMBER_OF_COMPONENTS);
+    }
+}
Index: /trunk/src/com/drew/metadata/jpeg/JpegReader.java
===================================================================
--- /trunk/src/com/drew/metadata/jpeg/JpegReader.java	(revision 4231)
+++ /trunk/src/com/drew/metadata/jpeg/JpegReader.java	(revision 4231)
@@ -0,0 +1,146 @@
+/*
+ * This is public domain software - that is, you can do whatever you want
+ * with it, and include it software that is licensed under the GNU or the
+ * BSD license, or whatever other licence you choose, including proprietary
+ * closed source licenses.  I do ask that you leave this header in tact.
+ *
+ * If you make modifications to this code that you think would benefit the
+ * wider community, please send me a copy and I'll post it on my site.
+ *
+ * If you make use of this code, I'd appreciate hearing about it.
+ *   drew@drewnoakes.com
+ * Latest version of this software kept at
+ *   http://drewnoakes.com/
+ *
+ * Created by dnoakes on Aug 2, 2003 using IntelliJ IDEA.
+ */
+package com.drew.metadata.jpeg;
+
+import com.drew.imaging.jpeg.JpegProcessingException;
+import com.drew.imaging.jpeg.JpegSegmentReader;
+import com.drew.metadata.Metadata;
+import com.drew.metadata.MetadataException;
+import com.drew.metadata.MetadataReader;
+
+import java.io.File;
+import java.io.InputStream;
+
+/**
+ *
+ * @author Darrell Silver http://www.darrellsilver.com and Drew Noakes
+ */
+public class JpegReader implements MetadataReader
+{
+    /**
+     * The SOF0 data segment.
+     */
+    private final byte[] _data;
+
+    /**
+     * Creates a new JpegReader for the specified Jpeg jpegFile.
+     */
+    public JpegReader(File jpegFile) throws JpegProcessingException
+    {
+        this(new JpegSegmentReader(jpegFile).readSegment(JpegSegmentReader.SEGMENT_SOF0));
+    }
+
+    /** Creates a JpegReader for a JPEG stream.
+     *
+     * @param is JPEG stream. Stream will be closed.
+     */
+    public JpegReader(InputStream is) throws JpegProcessingException
+    {
+        this(new JpegSegmentReader(is).readSegment(JpegSegmentReader.SEGMENT_APPD));
+    }
+
+    public JpegReader(byte[] data)
+    {
+        _data = data;
+    }
+
+    /**
+     * Performs the Jpeg data extraction, returning a new instance of <code>Metadata</code>.
+     */
+    public Metadata extract()
+    {
+        return extract(new Metadata());
+    }
+
+    /**
+     * Performs the Jpeg data extraction, adding found values to the specified
+     * instance of <code>Metadata</code>.
+     */
+    public Metadata extract(Metadata metadata)
+    {
+        if (_data==null) {
+            return metadata;
+        }
+
+        JpegDirectory directory = (JpegDirectory)metadata.getDirectory(JpegDirectory.class);
+
+        try {
+            // data precision
+            int dataPrecision = get16Bits(JpegDirectory.TAG_JPEG_DATA_PRECISION);
+            directory.setInt(JpegDirectory.TAG_JPEG_DATA_PRECISION, dataPrecision);
+
+            // process height
+            int height = get32Bits(JpegDirectory.TAG_JPEG_IMAGE_HEIGHT);
+            directory.setInt(JpegDirectory.TAG_JPEG_IMAGE_HEIGHT, height);
+
+            // process width
+            int width = get32Bits(JpegDirectory.TAG_JPEG_IMAGE_WIDTH);
+            directory.setInt(JpegDirectory.TAG_JPEG_IMAGE_WIDTH, width);
+
+            // number of components
+            int numberOfComponents = get16Bits(JpegDirectory.TAG_JPEG_NUMBER_OF_COMPONENTS);
+            directory.setInt(JpegDirectory.TAG_JPEG_NUMBER_OF_COMPONENTS, numberOfComponents);
+
+            // for each component, there are three bytes of data:
+            // 1 - Component ID: 1 = Y, 2 = Cb, 3 = Cr, 4 = I, 5 = Q
+            // 2 - Sampling factors: bit 0-3 vertical, 4-7 horizontal
+            // 3 - Quantization table number
+            int offset = 6;
+            for (int i=0; i<numberOfComponents; i++)
+            {
+                int componentId = get16Bits(offset++);
+                int samplingFactorByte = get16Bits(offset++);
+                int quantizationTableNumber = get16Bits(offset++);
+                JpegComponent component = new JpegComponent(componentId, samplingFactorByte, quantizationTableNumber);
+                directory.setObject(JpegDirectory.TAG_JPEG_COMPONENT_DATA_1 + i, component);
+            }
+
+        } catch (MetadataException me) {
+            directory.addError("MetadataException: " + me);
+        }
+
+        return metadata;
+    }
+
+    /**
+     * Returns an int calculated from two bytes of data at the specified offset (MSB, LSB).
+     * @param offset position within the data buffer to read first byte
+     * @return the 32 bit int value, between 0x0000 and 0xFFFF
+     */
+    private int get32Bits(int offset) throws MetadataException
+    {
+        if (offset+1>=_data.length) {
+            throw new MetadataException("Attempt to read bytes from outside Jpeg segment data buffer");
+        }
+
+        return ((_data[offset] & 255) << 8) | (_data[offset + 1] & 255);
+    }
+
+    /**
+     * Returns an int calculated from one byte of data at the specified offset.
+     * @param offset position within the data buffer to read byte
+     * @return the 16 bit int value, between 0x00 and 0xFF
+     */
+    private int get16Bits(int offset) throws MetadataException
+    {
+        if (offset>=_data.length) {
+            throw new MetadataException("Attempt to read bytes from outside Jpeg segment data buffer");
+        }
+
+        return (_data[offset] & 255);
+    }
+}
Index: /trunk/src/com/google/gdata/util/common/base/Escaper.java
===================================================================
--- /trunk/src/com/google/gdata/util/common/base/Escaper.java	(revision 4231)
+++ /trunk/src/com/google/gdata/util/common/base/Escaper.java	(revision 4231)
@@ -0,0 +1,88 @@
+/* Copyright (c) 2008 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+package com.google.gdata.util.common.base;
+
+/**
+ * An object that converts literal text into a format safe for inclusion in a
+ * particular context (such as an XML document). Typically (but not always), the
+ * inverse process of "unescaping" the text is performed automatically by the
+ * relevant parser.
+ *
+ * <p>For example, an XML escaper would convert the literal string {@code
+ * "Foo<Bar>"} into {@code "Foo&lt;Bar&gt;"} to prevent {@code "<Bar>"} from
+ * being confused with an XML tag. When the resulting XML document is parsed,
+ * the parser API will return this text as the original literal string {@code
+ * "Foo<Bar>"}.
+ *
+ * <p>An {@code Escaper} instance is required to be stateless, and safe when
+ * used concurrently by multiple threads.
+ *
+ * <p>Several popular escapers are defined as constants in the class {@link
+ * CharEscapers}. To create your own escapers, use {@link
+ * CharEscaperBuilder}, or extend {@link CharEscaper} or {@code UnicodeEscaper}.
+ *
+ * 
+ */
+public interface Escaper {
+  /**
+   * Returns the escaped form of a given literal string.
+   *
+   * <p>Note that this method may treat input characters differently depending on
+   * the specific escaper implementation.
+   * <ul>
+   * <li>{@link UnicodeEscaper} handles
+   * <a href="http://en.wikipedia.org/wiki/UTF-16">UTF-16</a> correctly,
+   * including surrogate character pairs. If the input is badly formed the
+   * escaper should throw {@link IllegalArgumentException}.
+   * <li>{@link CharEscaper} handles Java characters independently and does not
+   * verify the input for well formed characters. A CharEscaper should not be
+   * used in situations where input is not guaranteed to be restricted to the
+   * Basic Multilingual Plane (BMP).
+   * </ul>
+   *
+   * @param string the literal string to be escaped
+   * @return the escaped form of {@code string}
+   * @throws NullPointerException if {@code string} is null
+   * @throws IllegalArgumentException if {@code string} contains badly formed
+   *     UTF-16 or cannot be escaped for any other reason
+   */
+  public String escape(String string);
+
+  /**
+   * Returns an {@code Appendable} instance which automatically escapes all
+   * text appended to it before passing the resulting text to an underlying
+   * {@code Appendable}.
+   *
+   * <p>Note that this method may treat input characters differently depending on
+   * the specific escaper implementation.
+   * <ul>
+   * <li>{@link UnicodeEscaper} handles
+   * <a href="http://en.wikipedia.org/wiki/UTF-16">UTF-16</a> correctly,
+   * including surrogate character pairs. If the input is badly formed the
+   * escaper should throw {@link IllegalArgumentException}.
+   * <li>{@link CharEscaper} handles Java characters independently and does not
+   * verify the input for well formed characters. A CharEscaper should not be
+   * used in situations where input is not guaranteed to be restricted to the
+   * Basic Multilingual Plane (BMP).
+   * </ul>
+   *
+   * @param out the underlying {@code Appendable} to append escaped output to
+   * @return an {@code Appendable} which passes text to {@code out} after
+   *     escaping it.
+   */
+  public Appendable escape(Appendable out);
+}
Index: /trunk/src/com/google/gdata/util/common/base/PercentEscaper.java
===================================================================
--- /trunk/src/com/google/gdata/util/common/base/PercentEscaper.java	(revision 4231)
+++ /trunk/src/com/google/gdata/util/common/base/PercentEscaper.java	(revision 4231)
@@ -0,0 +1,275 @@
+/* Copyright (c) 2008 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+package com.google.gdata.util.common.base;
+
+/**
+ * A {@code UnicodeEscaper} that escapes some set of Java characters using
+ * the URI percent encoding scheme. The set of safe characters (those which
+ * remain unescaped) can be specified on construction.
+ *
+ * <p>For details on escaping URIs for use in web pages, see section 2.4 of
+ * <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986</a>.
+ *
+ * <p>In most cases this class should not need to be used directly. If you
+ * have no special requirements for escaping your URIs, you should use either
+ * {@link CharEscapers#uriEscaper()} or
+ * {@link CharEscapers#uriEscaper(boolean)}.
+ *
+ * <p>When encoding a String, the following rules apply:
+ * <ul>
+ * <li>The alphanumeric characters "a" through "z", "A" through "Z" and "0"
+ * through "9" remain the same.
+ * <li>Any additionally specified safe characters remain the same.
+ * <li>If {@code plusForSpace} was specified, the space character " " is
+ * converted into a plus sign "+".
+ * <li>All other characters are converted into one or more bytes using UTF-8
+ *     encoding and each byte is then represented by the 3-character string
+ *     "%XY", where "XY" is the two-digit, uppercase, hexadecimal representation
+ *     of the byte value.
+ * </ul>
+ *
+ * <p>RFC 2396 specifies the set of unreserved characters as "-", "_", ".", "!",
+ * "~", "*", "'", "(" and ")". It goes on to state:
+ *
+ * <p><i>Unreserved characters can be escaped without changing the semantics
+ * of the URI, but this should not be done unless the URI is being used
+ * in a context that does not allow the unescaped character to appear.</i>
+ *
+ * <p>For performance reasons the only currently supported character encoding of
+ * this class is UTF-8.
+ *
+ * <p><b>Note</b>: This escaper produces uppercase hexidecimal sequences. From
+ * <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986</a>:<br>
+ * <i>"URI producers and normalizers should use uppercase hexadecimal digits
+ * for all percent-encodings."</i>
+ *
+ * 
+ */
+public class PercentEscaper extends UnicodeEscaper {
+  /**
+   * A string of safe characters that mimics the behavior of
+   * {@link java.net.URLEncoder}.
+   *
+   */
+  public static final String SAFECHARS_URLENCODER = "-_.*";
+
+  /**
+   * A string of characters that do not need to be encoded when used in URI
+   * path segments, as specified in RFC 3986. Note that some of these
+   * characters do need to be escaped when used in other parts of the URI.
+   */
+  public static final String SAFEPATHCHARS_URLENCODER = "-_.!~*'()@:$&,;=";
+
+  /**
+   * A string of characters that do not need to be encoded when used in URI
+   * query strings, as specified in RFC 3986. Note that some of these
+   * characters do need to be escaped when used in other parts of the URI.
+   */
+  public static final String SAFEQUERYSTRINGCHARS_URLENCODER
+      = "-_.!~*'()@:$,;/?:";
+  
+  // In some uri escapers spaces are escaped to '+'
+  private static final char[] URI_ESCAPED_SPACE = { '+' };
+
+  private static final char[] UPPER_HEX_DIGITS =
+      "0123456789ABCDEF".toCharArray();
+
+  /**
+   * If true we should convert space to the {@code +} character.
+   */
+  private final boolean plusForSpace;
+
+  /**
+   * An array of flags where for any {@code char c} if {@code safeOctets[c]} is
+   * true then {@code c} should remain unmodified in the output. If
+   * {@code c > safeOctets.length} then it should be escaped.
+   */
+  private final boolean[] safeOctets;
+
+  /**
+   * Constructs a URI escaper with the specified safe characters and optional
+   * handling of the space character.
+   *
+   * @param safeChars a non null string specifying additional safe characters
+   *        for this escaper (the ranges 0..9, a..z and A..Z are always safe and
+   *        should not be specified here)
+   * @param plusForSpace true if ASCII space should be escaped to {@code +}
+   *        rather than {@code %20}
+   * @throws IllegalArgumentException if any of the parameters were invalid
+   */
+  public PercentEscaper(String safeChars, boolean plusForSpace) {
+    // Avoid any misunderstandings about the behavior of this escaper
+    if (safeChars.matches(".*[0-9A-Za-z].*")) {
+      throw new IllegalArgumentException(
+          "Alphanumeric characters are always 'safe' and should not be " +
+          "explicitly specified");
+    }
+    // Avoid ambiguous parameters. Safe characters are never modified so if
+    // space is a safe character then setting plusForSpace is meaningless.
+    if (plusForSpace && safeChars.contains(" ")) {
+      throw new IllegalArgumentException(
+          "plusForSpace cannot be specified when space is a 'safe' character");
+    }
+    if (safeChars.contains("%")) {
+      throw new IllegalArgumentException(
+          "The '%' character cannot be specified as 'safe'");
+    }
+    this.plusForSpace = plusForSpace;
+    this.safeOctets = createSafeOctets(safeChars);
+  }
+
+  /**
+   * Creates a boolean[] with entries corresponding to the character values
+   * for 0-9, A-Z, a-z and those specified in safeChars set to true. The array
+   * is as small as is required to hold the given character information.
+   */
+  private static boolean[] createSafeOctets(String safeChars) {
+    int maxChar = 'z';
+    char[] safeCharArray = safeChars.toCharArray();
+    for (char c : safeCharArray) {
+      maxChar = Math.max(c, maxChar);
+    }
+    boolean[] octets = new boolean[maxChar + 1];
+    for (int c = '0'; c <= '9'; c++) {
+      octets[c] = true;
+    }
+    for (int c = 'A'; c <= 'Z'; c++) {
+      octets[c] = true;
+    }
+    for (int c = 'a'; c <= 'z'; c++) {
+      octets[c] = true;
+    }
+    for (char c : safeCharArray) {
+      octets[c] = true;
+    }
+    return octets;
+  }
+
+  /*
+   * Overridden for performance. For unescaped strings this improved the
+   * performance of the uri escaper from ~760ns to ~400ns as measured by
+   * {@link CharEscapersBenchmark}.
+   */
+  @Override
+  protected int nextEscapeIndex(CharSequence csq, int index, int end) {
+    for (; index < end; index++) {
+      char c = csq.charAt(index);
+      if (c >= safeOctets.length || !safeOctets[c]) {
+        break;
+      }
+    }
+    return index;
+  }
+
+  /*
+   * Overridden for performance. For unescaped strings this improved the
+   * performance of the uri escaper from ~400ns to ~170ns as measured by
+   * {@link CharEscapersBenchmark}.
+   */
+  @Override
+  public String escape(String s) {
+    int slen = s.length();
+    for (int index = 0; index < slen; index++) {
+      char c = s.charAt(index);
+      if (c >= safeOctets.length || !safeOctets[c]) {
+        return escapeSlow(s, index);
+      }
+    }
+    return s;
+  }
+
+  /**
+   * Escapes the given Unicode code point in UTF-8.
+   */
+  @Override
+  protected char[] escape(int cp) {
+    // We should never get negative values here but if we do it will throw an
+    // IndexOutOfBoundsException, so at least it will get spotted.
+    if (cp < safeOctets.length && safeOctets[cp]) {
+      return null;
+    } else if (cp == ' ' && plusForSpace) {
+      return URI_ESCAPED_SPACE;
+    } else if (cp <= 0x7F) {
+      // Single byte UTF-8 characters
+      // Start with "%--" and fill in the blanks
+      char[] dest = new char[3];
+      dest[0] = '%';
+      dest[2] = UPPER_HEX_DIGITS[cp & 0xF];
+      dest[1] = UPPER_HEX_DIGITS[cp >>> 4];
+      return dest;
+    } else if (cp <= 0x7ff) {
+      // Two byte UTF-8 characters [cp >= 0x80 && cp <= 0x7ff]
+      // Start with "%--%--" and fill in the blanks
+      char[] dest = new char[6];
+      dest[0] = '%';
+      dest[3] = '%';
+      dest[5] = UPPER_HEX_DIGITS[cp & 0xF];
+      cp >>>= 4;
+      dest[4] = UPPER_HEX_DIGITS[0x8 | (cp & 0x3)];
+      cp >>>= 2;
+      dest[2] = UPPER_HEX_DIGITS[cp & 0xF];
+      cp >>>= 4;
+      dest[1] = UPPER_HEX_DIGITS[0xC | cp];
+      return dest;
+    } else if (cp <= 0xffff) {
+      // Three byte UTF-8 characters [cp >= 0x800 && cp <= 0xffff]
+      // Start with "%E-%--%--" and fill in the blanks
+      char[] dest = new char[9];
+      dest[0] = '%';
+      dest[1] = 'E';
+      dest[3] = '%';
+      dest[6] = '%';
+      dest[8] = UPPER_HEX_DIGITS[cp & 0xF];
+      cp >>>= 4;
+      dest[7] = UPPER_HEX_DIGITS[0x8 | (cp & 0x3)];
+      cp >>>= 2;
+      dest[5] = UPPER_HEX_DIGITS[cp & 0xF];
+      cp >>>= 4;
+      dest[4] = UPPER_HEX_DIGITS[0x8 | (cp & 0x3)];
+      cp >>>= 2;
+      dest[2] = UPPER_HEX_DIGITS[cp];
+      return dest;
+    } else if (cp <= 0x10ffff) {
+      char[] dest = new char[12];
+      // Four byte UTF-8 characters [cp >= 0xffff && cp <= 0x10ffff]
+      // Start with "%F-%--%--%--" and fill in the blanks
+      dest[0] = '%';
+      dest[1] = 'F';
+      dest[3] = '%';
+      dest[6] = '%';
+      dest[9] = '%';
+      dest[11] = UPPER_HEX_DIGITS[cp & 0xF];
+      cp >>>= 4;
+      dest[10] = UPPER_HEX_DIGITS[0x8 | (cp & 0x3)];
+      cp >>>= 2;
+      dest[8] = UPPER_HEX_DIGITS[cp & 0xF];
+      cp >>>= 4;
+      dest[7] = UPPER_HEX_DIGITS[0x8 | (cp & 0x3)];
+      cp >>>= 2;
+      dest[5] = UPPER_HEX_DIGITS[cp & 0xF];
+      cp >>>= 4;
+      dest[4] = UPPER_HEX_DIGITS[0x8 | (cp & 0x3)];
+      cp >>>= 2;
+      dest[2] = UPPER_HEX_DIGITS[cp & 0x7];
+      return dest;
+    } else {
+      // If this ever happens it is due to bug in UnicodeEscaper, not bad input.
+      throw new IllegalArgumentException(
+          "Invalid unicode character value " + cp);
+    }
+  }
+}
Index: /trunk/src/com/google/gdata/util/common/base/Preconditions.java
===================================================================
--- /trunk/src/com/google/gdata/util/common/base/Preconditions.java	(revision 4231)
+++ /trunk/src/com/google/gdata/util/common/base/Preconditions.java	(revision 4231)
@@ -0,0 +1,465 @@
+/*
+ * Copyright (C) 2007 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.gdata.util.common.base;
+
+import java.util.Collection;
+import java.util.NoSuchElementException;
+
+/**
+ * Simple static methods to be called at the start of your own methods to verify
+ * correct arguments and state. This allows constructs such as
+ * <pre>
+ *     if (count <= 0) {
+ *       throw new IllegalArgumentException("must be positive: " + count);
+ *     }</pre>
+ *
+ * to be replaced with the more compact
+ * <pre>
+ *     checkArgument(count > 0, "must be positive: %s", count);</pre>
+ *
+ * Note that the sense of the expression is inverted; with {@code Preconditions}
+ * you declare what you expect to be <i>true</i>, just as you do with an
+ * <a href="http://java.sun.com/j2se/1.5.0/docs/guide/language/assert.html">
+ * {@code assert}</a> or a JUnit {@code assertTrue()} call.
+ *
+ * <p>Take care not to confuse precondition checking with other similar types
+ * of checks! Precondition exceptions -- including those provided here, but also
+ * {@link IndexOutOfBoundsException}, {@link NoSuchElementException}, {@link
+ * UnsupportedOperationException} and others -- are used to signal that the
+ * <i>calling method</i> has made an error. This tells the caller that it should
+ * not have invoked the method when it did, with the arguments it did, or
+ * perhaps <i>ever</i>. Postcondition or other invariant failures should not
+ * throw these types of exceptions.
+ *
+ * <p><b>Note:</b> The methods of the {@code Preconditions} class are highly
+ * unusual in one way: they are <i>supposed to</i> throw exceptions, and promise
+ * in their specifications to do so even when given perfectly valid input. That
+ * is, {@code null} is a valid parameter to the method {@link
+ * #checkNotNull(Object)} -- and technically this parameter could be even marked
+ * as {@link Nullable} -- yet the method will still throw an exception anyway,
+ * because that's what its contract says to do.
+ * 
+ * <p>This class may be used with the Google Web Toolkit (GWT).
+ *
+ * 
+ */
+public final class Preconditions {
+  private Preconditions() {}
+
+  /**
+   * Ensures the truth of an expression involving one or more parameters to the
+   * calling method.
+   *
+   * @param expression a boolean expression
+   * @throws IllegalArgumentException if {@code expression} is false
+   */
+  public static void checkArgument(boolean expression) {
+    if (!expression) {
+      throw new IllegalArgumentException();
+    }
+  }
+
+  /**
+   * Ensures the truth of an expression involving one or more parameters to the
+   * calling method.
+   *
+   * @param expression a boolean expression
+   * @param errorMessage the exception message to use if the check fails; will
+   *     be converted to a string using {@link String#valueOf(Object)}
+   * @throws IllegalArgumentException if {@code expression} is false
+   */
+  public static void checkArgument(boolean expression, Object errorMessage) {
+    if (!expression) {
+      throw new IllegalArgumentException(String.valueOf(errorMessage));
+    }
+  }
+
+  /**
+   * Ensures the truth of an expression involving one or more parameters to the
+   * calling method.
+   *
+   * @param expression a boolean expression
+   * @param errorMessageTemplate a template for the exception message should the
+   *     check fail. The message is formed by replacing each {@code %s}
+   *     placeholder in the template with an argument. These are matched by
+   *     position - the first {@code %s} gets {@code errorMessageArgs[0]}, etc.
+   *     Unmatched arguments will be appended to the formatted message in square
+   *     braces. Unmatched placeholders will be left as-is.
+   * @param errorMessageArgs the arguments to be substituted into the message
+   *     template. Arguments are converted to strings using
+   *     {@link String#valueOf(Object)}.
+   * @throws IllegalArgumentException if {@code expression} is false
+   * @throws NullPointerException if the check fails and either {@code
+   *     errorMessageTemplate} or {@code errorMessageArgs} is null (don't let
+   *     this happen)
+   */
+  public static void checkArgument(boolean expression,
+      String errorMessageTemplate, Object... errorMessageArgs) {
+    if (!expression) {
+      throw new IllegalArgumentException(
+          format(errorMessageTemplate, errorMessageArgs));
+    }
+  }
+
+  /**
+   * Ensures the truth of an expression involving the state of the calling
+   * instance, but not involving any parameters to the calling method.
+   *
+   * @param expression a boolean expression
+   * @throws IllegalStateException if {@code expression} is false
+   */
+  public static void checkState(boolean expression) {
+    if (!expression) {
+      throw new IllegalStateException();
+    }
+  }
+
+  /**
+   * Ensures the truth of an expression involving the state of the calling
+   * instance, but not involving any parameters to the calling method.
+   *
+   * @param expression a boolean expression
+   * @param errorMessage the exception message to use if the check fails; will
+   *     be converted to a string using {@link String#valueOf(Object)}
+   * @throws IllegalStateException if {@code expression} is false
+   */
+  public static void checkState(boolean expression, Object errorMessage) {
+    if (!expression) {
+      throw new IllegalStateException(String.valueOf(errorMessage));
+    }
+  }
+
+  /**
+   * Ensures the truth of an expression involving the state of the calling
+   * instance, but not involving any parameters to the calling method.
+   *
+   * @param expression a boolean expression
+   * @param errorMessageTemplate a template for the exception message should the
+   *     check fail. The message is formed by replacing each {@code %s}
+   *     placeholder in the template with an argument. These are matched by
+   *     position - the first {@code %s} gets {@code errorMessageArgs[0]}, etc.
+   *     Unmatched arguments will be appended to the formatted message in square
+   *     braces. Unmatched placeholders will be left as-is.
+   * @param errorMessageArgs the arguments to be substituted into the message
+   *     template. Arguments are converted to strings using
+   *     {@link String#valueOf(Object)}.
+   * @throws IllegalStateException if {@code expression} is false
+   * @throws NullPointerException if the check fails and either {@code
+   *     errorMessageTemplate} or {@code errorMessageArgs} is null (don't let
+   *     this happen)
+   */
+  public static void checkState(boolean expression,
+      String errorMessageTemplate, Object... errorMessageArgs) {
+    if (!expression) {
+      throw new IllegalStateException(
+          format(errorMessageTemplate, errorMessageArgs));
+    }
+  }
+
+  /**
+   * Ensures that an object reference passed as a parameter to the calling
+   * method is not null.
+   *
+   * @param reference an object reference
+   * @return the non-null reference that was validated
+   * @throws NullPointerException if {@code reference} is null
+   */
+  public static <T> T checkNotNull(T reference) {
+    if (reference == null) {
+      throw new NullPointerException();
+    }
+    return reference;
+  }
+
+  /**
+   * Ensures that an object reference passed as a parameter to the calling
+   * method is not null.
+   *
+   * @param reference an object reference
+   * @param errorMessage the exception message to use if the check fails; will
+   *     be converted to a string using {@link String#valueOf(Object)}
+   * @return the non-null reference that was validated
+   * @throws NullPointerException if {@code reference} is null
+   */
+  public static <T> T checkNotNull(T reference, Object errorMessage) {
+    if (reference == null) {
+      throw new NullPointerException(String.valueOf(errorMessage));
+    }
+    return reference;
+  }
+
+  /**
+   * Ensures that an object reference passed as a parameter to the calling
+   * method is not null.
+   *
+   * @param reference an object reference
+   * @param errorMessageTemplate a template for the exception message should the
+   *     check fail. The message is formed by replacing each {@code %s}
+   *     placeholder in the template with an argument. These are matched by
+   *     position - the first {@code %s} gets {@code errorMessageArgs[0]}, etc.
+   *     Unmatched arguments will be appended to the formatted message in square
+   *     braces. Unmatched placeholders will be left as-is.
+   * @param errorMessageArgs the arguments to be substituted into the message
+   *     template. Arguments are converted to strings using
+   *     {@link String#valueOf(Object)}.
+   * @return the non-null reference that was validated
+   * @throws NullPointerException if {@code reference} is null
+   */
+  public static <T> T checkNotNull(T reference, String errorMessageTemplate,
+      Object... errorMessageArgs) {
+    if (reference == null) {
+      // If either of these parameters is null, the right thing happens anyway
+      throw new NullPointerException(
+          format(errorMessageTemplate, errorMessageArgs));
+    }
+    return reference;
+  }
+
+  /**
+   * Ensures that an {@code Iterable} object passed as a parameter to the
+   * calling method is not null and contains no null elements.
+   *
+   * @param iterable the iterable to check the contents of
+   * @return the non-null {@code iterable} reference just validated
+   * @throws NullPointerException if {@code iterable} is null or contains at
+   *     least one null element
+   */
+  public static <T extends Iterable<?>> T checkContentsNotNull(T iterable) {
+    if (containsOrIsNull(iterable)) {
+      throw new NullPointerException();
+    }
+    return iterable;
+  }
+
+  /**
+   * Ensures that an {@code Iterable} object passed as a parameter to the
+   * calling method is not null and contains no null elements.
+   *
+   * @param iterable the iterable to check the contents of
+   * @param errorMessage the exception message to use if the check fails; will
+   *     be converted to a string using {@link String#valueOf(Object)}
+   * @return the non-null {@code iterable} reference just validated
+   * @throws NullPointerException if {@code iterable} is null or contains at
+   *     least one null element
+   */
+  public static <T extends Iterable<?>> T checkContentsNotNull(
+      T iterable, Object errorMessage) {
+    if (containsOrIsNull(iterable)) {
+      throw new NullPointerException(String.valueOf(errorMessage));
+    }
+    return iterable;
+  }
+
+  /**
+   * Ensures that an {@code Iterable} object passed as a parameter to the
+   * calling method is not null and contains no null elements.
+   *
+   * @param iterable the iterable to check the contents of
+   * @param errorMessageTemplate a template for the exception message should the
+   *     check fail. The message is formed by replacing each {@code %s}
+   *     placeholder in the template with an argument. These are matched by
+   *     position - the first {@code %s} gets {@code errorMessageArgs[0]}, etc.
+   *     Unmatched arguments will be appended to the formatted message in square
+   *     braces. Unmatched placeholders will be left as-is.
+   * @param errorMessageArgs the arguments to be substituted into the message
+   *     template. Arguments are converted to strings using
+   *     {@link String#valueOf(Object)}.
+   * @return the non-null {@code iterable} reference just validated
+   * @throws NullPointerException if {@code iterable} is null or contains at
+   *     least one null element
+   */
+  public static <T extends Iterable<?>> T checkContentsNotNull(T iterable,
+      String errorMessageTemplate, Object... errorMessageArgs) {
+    if (containsOrIsNull(iterable)) {
+      throw new NullPointerException(
+          format(errorMessageTemplate, errorMessageArgs));
+    }
+    return iterable;
+  }
+  
+  private static boolean containsOrIsNull(Iterable<?> iterable) {
+    if (iterable == null) {
+      return true;
+    }
+    
+    if (iterable instanceof Collection) {
+      Collection<?> collection = (Collection<?>) iterable;
+      try {
+        return collection.contains(null);
+      } catch (NullPointerException e) {
+        // A NPE implies that the collection doesn't contain null.
+        return false;
+      }
+    } else {
+      for (Object element : iterable) {
+        if (element == null) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+
+  /**
+   * Ensures that {@code index} specifies a valid <i>element</i> in an array,
+   * list or string of size {@code size}. An element index may range from zero,
+   * inclusive, to {@code size}, exclusive.
+   *
+   * @param index a user-supplied index identifying an element of an array, list
+   *     or string
+   * @param size the size of that array, list or string
+   * @throws IndexOutOfBoundsException if {@code index} is negative or is not
+   *     less than {@code size}
+   * @throws IllegalArgumentException if {@code size} is negative
+   */
+  public static void checkElementIndex(int index, int size) {
+    checkElementIndex(index, size, "index");
+  }
+
+  /**
+   * Ensures that {@code index} specifies a valid <i>element</i> in an array,
+   * list or string of size {@code size}. An element index may range from zero,
+   * inclusive, to {@code size}, exclusive.
+   *
+   * @param index a user-supplied index identifying an element of an array, list
+   *     or string
+   * @param size the size of that array, list or string
+   * @param desc the text to use to describe this index in an error message
+   * @throws IndexOutOfBoundsException if {@code index} is negative or is not
+   *     less than {@code size}
+   * @throws IllegalArgumentException if {@code size} is negative
+   */
+  public static void checkElementIndex(int index, int size, String desc) {
+    checkArgument(size >= 0, "negative size: %s", size);
+    if (index < 0) {
+      throw new IndexOutOfBoundsException(
+          format("%s (%s) must not be negative", desc, index));
+    }
+    if (index >= size) {
+      throw new IndexOutOfBoundsException(
+          format("%s (%s) must be less than size (%s)", desc, index, size));
+    }
+  }
+
+  /**
+   * Ensures that {@code index} specifies a valid <i>position</i> in an array,
+   * list or string of size {@code size}. A position index may range from zero
+   * to {@code size}, inclusive.
+   *
+   * @param index a user-supplied index identifying a position in an array, list
+   *     or string
+   * @param size the size of that array, list or string
+   * @throws IndexOutOfBoundsException if {@code index} is negative or is
+   *     greater than {@code size}
+   * @throws IllegalArgumentException if {@code size} is negative
+   */
+  public static void checkPositionIndex(int index, int size) {
+    checkPositionIndex(index, size, "index");
+  }
+
+  /**
+   * Ensures that {@code index} specifies a valid <i>position</i> in an array,
+   * list or string of size {@code size}. A position index may range from zero
+   * to {@code size}, inclusive.
+   *
+   * @param index a user-supplied index identifying a position in an array, list
+   *     or string
+   * @param size the size of that array, list or string
+   * @param desc the text to use to describe this index in an error message
+   * @throws IndexOutOfBoundsException if {@code index} is negative or is
+   *     greater than {@code size}
+   * @throws IllegalArgumentException if {@code size} is negative
+   */
+  public static void checkPositionIndex(int index, int size, String desc) {
+    checkArgument(size >= 0, "negative size: %s", size);
+    if (index < 0) {
+      throw new IndexOutOfBoundsException(format(
+          "%s (%s) must not be negative", desc, index));
+    }
+    if (index > size) {
+      throw new IndexOutOfBoundsException(format(
+          "%s (%s) must not be greater than size (%s)", desc, index, size));
+    }
+  }
+
+  /**
+   * Ensures that {@code start} and {@code end} specify a valid <i>positions</i>
+   * in an array, list or string of size {@code size}, and are in order. A
+   * position index may range from zero to {@code size}, inclusive.
+   *
+   * @param start a user-supplied index identifying a starting position in an
+   *     array, list or string
+   * @param end a user-supplied index identifying a ending position in an array,
+   *     list or string
+   * @param size the size of that array, list or string
+   * @throws IndexOutOfBoundsException if either index is negative or is
+   *     greater than {@code size}, or if {@code end} is less than {@code start}
+   * @throws IllegalArgumentException if {@code size} is negative
+   */
+  public static void checkPositionIndexes(int start, int end, int size) {
+    checkPositionIndex(start, size, "start index");
+    checkPositionIndex(end, size, "end index");
+    if (end < start) {
+      throw new IndexOutOfBoundsException(format(
+          "end index (%s) must not be less than start index (%s)", end, start));
+    }
+  }
+
+  /**
+   * Substitutes each {@code %s} in {@code template} with an argument. These
+   * are matched by position - the first {@code %s} gets {@code args[0]}, etc.
+   * If there are more arguments than placeholders, the unmatched arguments will
+   * be appended to the end of the formatted message in square braces.
+   *
+   * @param template a non-null string containing 0 or more {@code %s}
+   *     placeholders.
+   * @param args the arguments to be substituted into the message
+   *     template. Arguments are converted to strings using
+   *     {@link String#valueOf(Object)}. Arguments can be null.
+   */
+  // VisibleForTesting
+  static String format(String template, Object... args) {
+    // start substituting the arguments into the '%s' placeholders
+    StringBuilder builder = new StringBuilder(
+        template.length() + 16 * args.length);
+    int templateStart = 0;
+    int i = 0;
+    while (i < args.length) {
+      int placeholderStart = template.indexOf("%s", templateStart);
+      if (placeholderStart == -1) {
+        break;
+      }
+      builder.append(template.substring(templateStart, placeholderStart));
+      builder.append(args[i++]);
+      templateStart = placeholderStart + 2;
+    }
+    builder.append(template.substring(templateStart));
+
+    // if we run out of placeholders, append the extra args in square braces
+    if (i < args.length) {
+      builder.append(" [");
+      builder.append(args[i++]);
+      while (i < args.length) {
+        builder.append(", ");
+        builder.append(args[i++]);
+      }
+      builder.append("]");
+    }
+
+    return builder.toString();
+  }
+}
Index: /trunk/src/com/google/gdata/util/common/base/UnicodeEscaper.java
===================================================================
--- /trunk/src/com/google/gdata/util/common/base/UnicodeEscaper.java	(revision 4231)
+++ /trunk/src/com/google/gdata/util/common/base/UnicodeEscaper.java	(revision 4231)
@@ -0,0 +1,460 @@
+/* Copyright (c) 2008 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+package com.google.gdata.util.common.base;
+
+import static com.google.gdata.util.common.base.Preconditions.checkNotNull;
+
+import java.io.IOException;
+
+/**
+ * An {@link Escaper} that converts literal text into a format safe for
+ * inclusion in a particular context (such as an XML document). Typically (but
+ * not always), the inverse process of "unescaping" the text is performed
+ * automatically by the relevant parser.
+ *
+ * <p>For example, an XML escaper would convert the literal string {@code
+ * "Foo<Bar>"} into {@code "Foo&lt;Bar&gt;"} to prevent {@code "<Bar>"} from
+ * being confused with an XML tag. When the resulting XML document is parsed,
+ * the parser API will return this text as the original literal string {@code
+ * "Foo<Bar>"}.
+ *
+ * <p><b>Note:</b> This class is similar to {@link CharEscaper} but with one
+ * very important difference. A CharEscaper can only process Java
+ * <a href="http://en.wikipedia.org/wiki/UTF-16">UTF16</a> characters in
+ * isolation and may not cope when it encounters surrogate pairs. This class
+ * facilitates the correct escaping of all Unicode characters.
+ *
+ * <p>As there are important reasons, including potential security issues, to
+ * handle Unicode correctly if you are considering implementing a new escaper
+ * you should favor using UnicodeEscaper wherever possible.
+ *
+ * <p>A {@code UnicodeEscaper} instance is required to be stateless, and safe
+ * when used concurrently by multiple threads.
+ *
+ * <p>Several popular escapers are defined as constants in the class {@link
+ * CharEscapers}. To create your own escapers extend this class and implement
+ * the {@link #escape(int)} method.
+ *
+ * 
+ */
+public abstract class UnicodeEscaper implements Escaper {
+  /** The amount of padding (chars) to use when growing the escape buffer. */
+  private static final int DEST_PAD = 32;
+
+  /**
+   * Returns the escaped form of the given Unicode code point, or {@code null}
+   * if this code point does not need to be escaped. When called as part of an
+   * escaping operation, the given code point is guaranteed to be in the range
+   * {@code 0 <= cp <= Character#MAX_CODE_POINT}.
+   *
+   * <p>If an empty array is returned, this effectively strips the input
+   * character from the resulting text.
+   *
+   * <p>If the character does not need to be escaped, this method should return
+   * {@code null}, rather than an array containing the character representation
+   * of the code point. This enables the escaping algorithm to perform more
+   * efficiently.
+   *
+   * <p>If the implementation of this method cannot correctly handle a
+   * particular code point then it should either throw an appropriate runtime
+   * exception or return a suitable replacement character. It must never
+   * silently discard invalid input as this may constitute a security risk.
+   *
+   * @param cp the Unicode code point to escape if necessary
+   * @return the replacement characters, or {@code null} if no escaping was
+   *     needed
+   */
+  protected abstract char[] escape(int cp);
+
+  /**
+   * Scans a sub-sequence of characters from a given {@link CharSequence},
+   * returning the index of the next character that requires escaping.
+   *
+   * <p><b>Note:</b> When implementing an escaper, it is a good idea to override
+   * this method for efficiency. The base class implementation determines
+   * successive Unicode code points and invokes {@link #escape(int)} for each of
+   * them. If the semantics of your escaper are such that code points in the
+   * supplementary range are either all escaped or all unescaped, this method
+   * can be implemented more efficiently using {@link CharSequence#charAt(int)}.
+   *
+   * <p>Note however that if your escaper does not escape characters in the
+   * supplementary range, you should either continue to validate the correctness
+   * of any surrogate characters encountered or provide a clear warning to users
+   * that your escaper does not validate its input.
+   *
+   * <p>See {@link PercentEscaper} for an example.
+   *
+   * @param csq a sequence of characters
+   * @param start the index of the first character to be scanned
+   * @param end the index immediately after the last character to be scanned
+   * @throws IllegalArgumentException if the scanned sub-sequence of {@code csq}
+   *     contains invalid surrogate pairs
+   */
+  protected int nextEscapeIndex(CharSequence csq, int start, int end) {
+    int index = start;
+    while (index < end) {
+      int cp = codePointAt(csq, index, end);
+      if (cp < 0 || escape(cp) != null) {
+        break;
+      }
+      index += Character.isSupplementaryCodePoint(cp) ? 2 : 1;
+    }
+    return index;
+  }
+
+  /**
+   * Returns the escaped form of a given literal string.
+   *
+   * <p>If you are escaping input in arbitrary successive chunks, then it is not
+   * generally safe to use this method. If an input string ends with an
+   * unmatched high surrogate character, then this method will throw
+   * {@link IllegalArgumentException}. You should either ensure your input is
+   * valid <a href="http://en.wikipedia.org/wiki/UTF-16">UTF-16</a> before
+   * calling this method or use an escaped {@link Appendable} (as returned by
+   * {@link #escape(Appendable)}) which can cope with arbitrarily split input.
+   *
+   * <p><b>Note:</b> When implementing an escaper it is a good idea to override
+   * this method for efficiency by inlining the implementation of
+   * {@link #nextEscapeIndex(CharSequence, int, int)} directly. Doing this for
+   * {@link PercentEscaper} more than doubled the performance for unescaped
+   * strings (as measured by {@link CharEscapersBenchmark}).
+   *
+   * @param string the literal string to be escaped
+   * @return the escaped form of {@code string}
+   * @throws NullPointerException if {@code string} is null
+   * @throws IllegalArgumentException if invalid surrogate characters are
+   *         encountered
+   */
+  public String escape(String string) {
+    int end = string.length();
+    int index = nextEscapeIndex(string, 0, end);
+    return index == end ? string : escapeSlow(string, index);
+  }
+
+  /**
+   * Returns the escaped form of a given literal string, starting at the given
+   * index.  This method is called by the {@link #escape(String)} method when it
+   * discovers that escaping is required.  It is protected to allow subclasses
+   * to override the fastpath escaping function to inline their escaping test.
+   * See {@link CharEscaperBuilder} for an example usage.
+   *
+   * <p>This method is not reentrant and may only be invoked by the top level
+   * {@link #escape(String)} method.
+   *
+   * @param s the literal string to be escaped
+   * @param index the index to start escaping from
+   * @return the escaped form of {@code string}
+   * @throws NullPointerException if {@code string} is null
+   * @throws IllegalArgumentException if invalid surrogate characters are
+   *         encountered
+   */
+  protected final String escapeSlow(String s, int index) {
+    int end = s.length();
+
+    // Get a destination buffer and setup some loop variables.
+    char[] dest = DEST_TL.get();
+    int destIndex = 0;
+    int unescapedChunkStart = 0;
+
+    while (index < end) {
+      int cp = codePointAt(s, index, end);
+      if (cp < 0) {
+        throw new IllegalArgumentException(
+            "Trailing high surrogate at end of input");
+      }
+      char[] escaped = escape(cp);
+      if (escaped != null) {
+        int charsSkipped = index - unescapedChunkStart;
+
+        // This is the size needed to add the replacement, not the full
+        // size needed by the string.  We only regrow when we absolutely must.
+        int sizeNeeded = destIndex + charsSkipped + escaped.length;
+        if (dest.length < sizeNeeded) {
+          int destLength = sizeNeeded + (end - index) + DEST_PAD;
+          dest = growBuffer(dest, destIndex, destLength);
+        }
+        // If we have skipped any characters, we need to copy them now.
+        if (charsSkipped > 0) {
+          s.getChars(unescapedChunkStart, index, dest, destIndex);
+          destIndex += charsSkipped;
+        }
+        if (escaped.length > 0) {
+          System.arraycopy(escaped, 0, dest, destIndex, escaped.length);
+          destIndex += escaped.length;
+        }
+      }
+      unescapedChunkStart
+          = index + (Character.isSupplementaryCodePoint(cp) ? 2 : 1);
+      index = nextEscapeIndex(s, unescapedChunkStart, end);
+    }
+
+    // Process trailing unescaped characters - no need to account for escaped
+    // length or padding the allocation.
+    int charsSkipped = end - unescapedChunkStart;
+    if (charsSkipped > 0) {
+      int endIndex = destIndex + charsSkipped;
+      if (dest.length < endIndex) {
+        dest = growBuffer(dest, destIndex, endIndex);
+      }
+      s.getChars(unescapedChunkStart, end, dest, destIndex);
+      destIndex = endIndex;
+    }
+    return new String(dest, 0, destIndex);
+  }
+
+  /**
+   * Returns an {@code Appendable} instance which automatically escapes all
+   * text appended to it before passing the resulting text to an underlying
+   * {@code Appendable}.
+   *
+   * <p>Unlike {@link #escape(String)} it is permitted to append arbitrarily
+   * split input to this Appendable, including input that is split over a
+   * surrogate pair. In this case the pending high surrogate character will not
+   * be processed until the corresponding low surrogate is appended. This means
+   * that a trailing high surrogate character at the end of the input cannot be
+   * detected and will be silently ignored. This is unavoidable since the
+   * Appendable interface has no {@code close()} method, and it is impossible to
+   * determine when the last characters have been appended.
+   *
+   * <p>The methods of the returned object will propagate any exceptions thrown
+   * by the underlying {@code Appendable}.
+   *
+   * <p>For well formed <a href="http://en.wikipedia.org/wiki/UTF-16">UTF-16</a>
+   * the escaping behavior is identical to that of {@link #escape(String)} and
+   * the following code is equivalent to (but much slower than)
+   * {@code escaper.escape(string)}: <pre>{@code
+   *
+   *   StringBuilder sb = new StringBuilder();
+   *   escaper.escape(sb).append(string);
+   *   return sb.toString();}</pre>
+   *
+   * @param out the underlying {@code Appendable} to append escaped output to
+   * @return an {@code Appendable} which passes text to {@code out} after
+   *     escaping it
+   * @throws NullPointerException if {@code out} is null
+   * @throws IllegalArgumentException if invalid surrogate characters are
+   *         encountered
+   *
+   */
+  public Appendable escape(final Appendable out) {
+    checkNotNull(out);
+
+    return new Appendable() {
+      int pendingHighSurrogate = -1;
+      char[] decodedChars = new char[2];
+
+      public Appendable append(CharSequence csq) throws IOException {
+        return append(csq, 0, csq.length());
+      }
+
+      public Appendable append(CharSequence csq, int start, int end)
+          throws IOException {
+        int index = start;
+        if (index < end) {
+          // This is a little subtle: index must never reference the middle of a
+          // surrogate pair but unescapedChunkStart can. The first time we enter
+          // the loop below it is possible that index != unescapedChunkStart.
+          int unescapedChunkStart = index;
+          if (pendingHighSurrogate != -1) {
+            // Our last append operation ended halfway through a surrogate pair
+            // so we have to do some extra work first.
+            char c = csq.charAt(index++);
+            if (!Character.isLowSurrogate(c)) {
+              throw new IllegalArgumentException(
+                  "Expected low surrogate character but got " + c);
+            }
+            char[] escaped =
+                escape(Character.toCodePoint((char) pendingHighSurrogate, c));
+            if (escaped != null) {
+              // Emit the escaped character and adjust unescapedChunkStart to
+              // skip the low surrogate we have consumed.
+              outputChars(escaped, escaped.length);
+              unescapedChunkStart += 1;
+            } else {
+              // Emit pending high surrogate (unescaped) but do not modify
+              // unescapedChunkStart as we must still emit the low surrogate.
+              out.append((char) pendingHighSurrogate);
+            }
+            pendingHighSurrogate = -1;
+          }
+          while (true) {
+            // Find and append the next subsequence of unescaped characters.
+            index = nextEscapeIndex(csq, index, end);
+            if (index > unescapedChunkStart) {
+              out.append(csq, unescapedChunkStart, index);
+            }
+            if (index == end) {
+              break;
+            }
+            // If we are not finished, calculate the next code point.
+            int cp = codePointAt(csq, index, end);
+            if (cp < 0) {
+              // Our sequence ended half way through a surrogate pair so just
+              // record the state and exit.
+              pendingHighSurrogate = -cp;
+              break;
+            }
+            // Escape the code point and output the characters.
+            char[] escaped = escape(cp);
+            if (escaped != null) {
+              outputChars(escaped, escaped.length);
+            } else {
+              // This shouldn't really happen if nextEscapeIndex is correct but
+              // we should cope with false positives.
+              int len = Character.toChars(cp, decodedChars, 0);
+              outputChars(decodedChars, len);
+            }
+            // Update our index past the escaped character and continue.
+            index += (Character.isSupplementaryCodePoint(cp) ? 2 : 1);
+            unescapedChunkStart = index;
+          }
+        }
+        return this;
+      }
+
+      public Appendable append(char c) throws IOException {
+        if (pendingHighSurrogate != -1) {
+          // Our last append operation ended halfway through a surrogate pair
+          // so we have to do some extra work first.
+          if (!Character.isLowSurrogate(c)) {
+            throw new IllegalArgumentException(
+                "Expected low surrogate character but got '" + c +
+                "' with value " + (int) c);
+          }
+          char[] escaped =
+              escape(Character.toCodePoint((char) pendingHighSurrogate, c));
+          if (escaped != null) {
+            outputChars(escaped, escaped.length);
+          } else {
+            out.append((char) pendingHighSurrogate);
+            out.append(c);
+          }
+          pendingHighSurrogate = -1;
+        } else if (Character.isHighSurrogate(c)) {
+          // This is the start of a (split) surrogate pair.
+          pendingHighSurrogate = c;
+        } else {
+          if (Character.isLowSurrogate(c)) {
+            throw new IllegalArgumentException(
+                "Unexpected low surrogate character '" + c +
+                "' with value " + (int) c);
+          }
+          // This is a normal (non surrogate) char.
+          char[] escaped = escape(c);
+          if (escaped != null) {
+            outputChars(escaped, escaped.length);
+          } else {
+            out.append(c);
+          }
+        }
+        return this;
+      }
+
+      private void outputChars(char[] chars, int len) throws IOException {
+        for (int n = 0; n < len; n++) {
+          out.append(chars[n]);
+        }
+      }
+    };
+  }
+
+  /**
+   * Returns the Unicode code point of the character at the given index.
+   *
+   * <p>Unlike {@link Character#codePointAt(CharSequence, int)} or
+   * {@link String#codePointAt(int)} this method will never fail silently when
+   * encountering an invalid surrogate pair.
+   *
+   * <p>The behaviour of this method is as follows:
+   * <ol>
+   * <li>If {@code index >= end}, {@link IndexOutOfBoundsException} is thrown.
+   * <li><b>If the character at the specified index is not a surrogate, it is
+   *     returned.</b>
+   * <li>If the first character was a high surrogate value, then an attempt is
+   *     made to read the next character.
+   *     <ol>
+   *     <li><b>If the end of the sequence was reached, the negated value of
+   *         the trailing high surrogate is returned.</b>
+   *     <li><b>If the next character was a valid low surrogate, the code point
+   *         value of the high/low surrogate pair is returned.</b>
+   *     <li>If the next character was not a low surrogate value, then
+   *         {@link IllegalArgumentException} is thrown.
+   *     </ol>
+   * <li>If the first character was a low surrogate value,
+   *     {@link IllegalArgumentException} is thrown.
+   * </ol>
+   *
+   * @param seq the sequence of characters from which to decode the code point
+   * @param index the index of the first character to decode
+   * @param end the index beyond the last valid character to decode
+   * @return the Unicode code point for the given index or the negated value of
+   *         the trailing high surrogate character at the end of the sequence
+   */
+  protected static final int codePointAt(CharSequence seq, int index, int end) {
+    if (index < end) {
+      char c1 = seq.charAt(index++);
+      if (c1 < Character.MIN_HIGH_SURROGATE ||
+          c1 > Character.MAX_LOW_SURROGATE) {
+        // Fast path (first test is probably all we need to do)
+        return c1;
+      } else if (c1 <= Character.MAX_HIGH_SURROGATE) {
+        // If the high surrogate was the last character, return its inverse
+        if (index == end) {
+          return -c1;
+        }
+        // Otherwise look for the low surrogate following it
+        char c2 = seq.charAt(index);
+        if (Character.isLowSurrogate(c2)) {
+          return Character.toCodePoint(c1, c2);
+        }
+        throw new IllegalArgumentException(
+            "Expected low surrogate but got char '" + c2 +
+            "' with value " + (int) c2 + " at index " + index);
+      } else {
+        throw new IllegalArgumentException(
+            "Unexpected low surrogate character '" + c1 +
+            "' with value " + (int) c1 + " at index " + (index - 1));
+      }
+    }
+    throw new IndexOutOfBoundsException("Index exceeds specified range");
+  }
+
+  /**
+   * Helper method to grow the character buffer as needed, this only happens
+   * once in a while so it's ok if it's in a method call.  If the index passed
+   * in is 0 then no copying will be done.
+   */
+  private static final char[] growBuffer(char[] dest, int index, int size) {
+    char[] copy = new char[size];
+    if (index > 0) {
+      System.arraycopy(dest, 0, copy, 0, index);
+    }
+    return copy;
+  }
+
+  /**
+   * A thread-local destination buffer to keep us from creating new buffers.
+   * The starting size is 1024 characters.  If we grow past this we don't
+   * put it back in the threadlocal, we just keep going and grow as needed.
+   */
+  private static final ThreadLocal<char[]> DEST_TL = new ThreadLocal<char[]>() {
+    @Override
+    protected char[] initialValue() {
+      return new char[1024];
+    }
+  };
+}
Index: /trunk/src/oauth/signpost/AbstractOAuthConsumer.java
===================================================================
--- /trunk/src/oauth/signpost/AbstractOAuthConsumer.java	(revision 4231)
+++ /trunk/src/oauth/signpost/AbstractOAuthConsumer.java	(revision 4231)
@@ -0,0 +1,261 @@
+/* Copyright (c) 2009 Matthias Kaeppler
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package oauth.signpost;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Random;
+
+import oauth.signpost.basic.UrlStringRequestAdapter;
+import oauth.signpost.exception.OAuthCommunicationException;
+import oauth.signpost.exception.OAuthExpectationFailedException;
+import oauth.signpost.exception.OAuthMessageSignerException;
+import oauth.signpost.http.HttpParameters;
+import oauth.signpost.http.HttpRequest;
+import oauth.signpost.signature.AuthorizationHeaderSigningStrategy;
+import oauth.signpost.signature.HmacSha1MessageSigner;
+import oauth.signpost.signature.OAuthMessageSigner;
+import oauth.signpost.signature.QueryStringSigningStrategy;
+import oauth.signpost.signature.SigningStrategy;
+
+/**
+ * ABC for consumer implementations. If you're developing a custom consumer you
+ * will probably inherit from this class to save you a lot of work.
+ * 
+ * @author Matthias Kaeppler
+ */
+public abstract class AbstractOAuthConsumer implements OAuthConsumer {
+
+    private static final long serialVersionUID = 1L;
+
+    private String consumerKey, consumerSecret;
+
+    private String token;
+
+    private OAuthMessageSigner messageSigner;
+
+    private SigningStrategy signingStrategy;
+
+    // these are params that may be passed to the consumer directly (i.e.
+    // without going through the request object)
+    private HttpParameters additionalParameters;
+
+    // these are the params which will be passed to the message signer
+    private HttpParameters requestParameters;
+
+    private boolean sendEmptyTokens;
+
+    public AbstractOAuthConsumer(String consumerKey, String consumerSecret) {
+        this.consumerKey = consumerKey;
+        this.consumerSecret = consumerSecret;
+        setMessageSigner(new HmacSha1MessageSigner());
+        setSigningStrategy(new AuthorizationHeaderSigningStrategy());
+    }
+
+    public void setMessageSigner(OAuthMessageSigner messageSigner) {
+        this.messageSigner = messageSigner;
+        messageSigner.setConsumerSecret(consumerSecret);
+    }
+
+    public void setSigningStrategy(SigningStrategy signingStrategy) {
+        this.signingStrategy = signingStrategy;
+    }
+
+    public void setAdditionalParameters(HttpParameters additionalParameters) {
+        this.additionalParameters = additionalParameters;
+    }
+
+    public HttpRequest sign(HttpRequest request) throws OAuthMessageSignerException,
+            OAuthExpectationFailedException, OAuthCommunicationException {
+        if (consumerKey == null) {
+            throw new OAuthExpectationFailedException("consumer key not set");
+        }
+        if (consumerSecret == null) {
+            throw new OAuthExpectationFailedException("consumer secret not set");
+        }
+
+        requestParameters = new HttpParameters();
+        try {
+            if (additionalParameters != null) {
+                requestParameters.putAll(additionalParameters, false);
+            }
+            collectHeaderParameters(request, requestParameters);
+            collectQueryParameters(request, requestParameters);
+            collectBodyParameters(request, requestParameters);
+
+            // add any OAuth params that haven't already been set
+            completeOAuthParameters(requestParameters);
+
+            requestParameters.remove(OAuth.OAUTH_SIGNATURE);
+
+        } catch (IOException e) {
+            throw new OAuthCommunicationException(e);
+        }
+
+        String signature = messageSigner.sign(request, requestParameters);
+        OAuth.debugOut("signature", signature);
+
+        signingStrategy.writeSignature(signature, request, requestParameters);
+        OAuth.debugOut("Auth header", request.getHeader("Authorization"));
+        OAuth.debugOut("Request URL", request.getRequestUrl());
+
+        return request;
+    }
+
+    public HttpRequest sign(Object request) throws OAuthMessageSignerException,
+            OAuthExpectationFailedException, OAuthCommunicationException {
+        return sign(wrap(request));
+    }
+
+    public String sign(String url) throws OAuthMessageSignerException,
+            OAuthExpectationFailedException, OAuthCommunicationException {
+        HttpRequest request = new UrlStringRequestAdapter(url);
+
+        // switch to URL signing
+        SigningStrategy oldStrategy = this.signingStrategy;
+        this.signingStrategy = new QueryStringSigningStrategy();
+
+        sign(request);
+
+        // revert to old strategy
+        this.signingStrategy = oldStrategy;
+
+        return request.getRequestUrl();
+    }
+
+    /**
+     * Adapts the given request object to a Signpost {@link HttpRequest}. How
+     * this is done depends on the consumer implementation.
+     * 
+     * @param request
+     *        the native HTTP request instance
+     * @return the adapted request
+     */
+    protected abstract HttpRequest wrap(Object request);
+
+    public void setTokenWithSecret(String token, String tokenSecret) {
+        this.token = token;
+        messageSigner.setTokenSecret(tokenSecret);
+    }
+
+    public String getToken() {
+        return token;
+    }
+
+    public String getTokenSecret() {
+        return messageSigner.getTokenSecret();
+    }
+
+    public String getConsumerKey() {
+        return this.consumerKey;
+    }
+
+    public String getConsumerSecret() {
+        return this.consumerSecret;
+    }
+
+    /**
+     * <p>
+     * Helper method that adds any OAuth parameters to the given request
+     * parameters which are missing from the current request but required for
+     * signing. A good example is the oauth_nonce parameter, which is typically
+     * not provided by the client in advance.
+     * </p>
+     * <p>
+     * It's probably not a very good idea to override this method. If you want
+     * to generate different nonces or timestamps, override
+     * {@link #generateNonce()} or {@link #generateTimestamp()} instead.
+     * </p>
+     * 
+     * @param out
+     *        the request parameter which should be completed
+     */
+    protected void completeOAuthParameters(HttpParameters out) {
+        if (!out.containsKey(OAuth.OAUTH_CONSUMER_KEY)) {
+            out.put(OAuth.OAUTH_CONSUMER_KEY, consumerKey, true);
+        }
+        if (!out.containsKey(OAuth.OAUTH_SIGNATURE_METHOD)) {
+            out.put(OAuth.OAUTH_SIGNATURE_METHOD, messageSigner.getSignatureMethod(), true);
+        }
+        if (!out.containsKey(OAuth.OAUTH_TIMESTAMP)) {
+            out.put(OAuth.OAUTH_TIMESTAMP, generateTimestamp(), true);
+        }
+        if (!out.containsKey(OAuth.OAUTH_NONCE)) {
+            out.put(OAuth.OAUTH_NONCE, generateNonce(), true);
+        }
+        if (!out.containsKey(OAuth.OAUTH_VERSION)) {
+            out.put(OAuth.OAUTH_VERSION, OAuth.VERSION_1_0, true);
+        }
+        if (!out.containsKey(OAuth.OAUTH_TOKEN)) {
+            if (token != null && !token.equals("") || sendEmptyTokens) {
+                out.put(OAuth.OAUTH_TOKEN, token, true);
+            }
+        }
+    }
+
+    public HttpParameters getRequestParameters() {
+        return requestParameters;
+    }
+
+    public void setSendEmptyTokens(boolean enable) {
+        this.sendEmptyTokens = enable;
+    }
+
+    /**
+     * Collects OAuth Authorization header parameters as per OAuth Core 1.0 spec
+     * section 9.1.1
+     */
+    protected void collectHeaderParameters(HttpRequest request, HttpParameters out) {
+        HttpParameters headerParams = OAuth.oauthHeaderToParamsMap(request.getHeader(OAuth.HTTP_AUTHORIZATION_HEADER));
+        out.putAll(headerParams, false);
+    }
+
+    /**
+     * Collects x-www-form-urlencoded body parameters as per OAuth Core 1.0 spec
+     * section 9.1.1
+     */
+    protected void collectBodyParameters(HttpRequest request, HttpParameters out)
+            throws IOException {
+
+        // collect x-www-form-urlencoded body params
+        String contentType = request.getContentType();
+        if (contentType != null && contentType.startsWith(OAuth.FORM_ENCODED)) {
+            InputStream payload = request.getMessagePayload();
+            out.putAll(OAuth.decodeForm(payload), true);
+        }
+    }
+
+    /**
+     * Collects HTTP GET query string parameters as per OAuth Core 1.0 spec
+     * section 9.1.1
+     */
+    protected void collectQueryParameters(HttpRequest request, HttpParameters out) {
+
+        String url = request.getRequestUrl();
+        int q = url.indexOf('?');
+        if (q >= 0) {
+            // Combine the URL query string with the other parameters:
+            out.putAll(OAuth.decodeForm(url.substring(q + 1)), true);
+        }
+    }
+
+    protected String generateTimestamp() {
+        return Long.toString(System.currentTimeMillis() / 1000L);
+    }
+
+    protected String generateNonce() {
+        return Long.toString(new Random().nextLong());
+    }
+}
Index: /trunk/src/oauth/signpost/AbstractOAuthProvider.java
===================================================================
--- /trunk/src/oauth/signpost/AbstractOAuthProvider.java	(revision 4231)
+++ /trunk/src/oauth/signpost/AbstractOAuthProvider.java	(revision 4231)
@@ -0,0 +1,341 @@
+/*
+ * Copyright (c) 2009 Matthias Kaeppler Licensed under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law
+ * or agreed to in writing, software distributed under the License is
+ * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+package oauth.signpost;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.util.HashMap;
+import java.util.Map;
+
+import oauth.signpost.exception.OAuthCommunicationException;
+import oauth.signpost.exception.OAuthExpectationFailedException;
+import oauth.signpost.exception.OAuthMessageSignerException;
+import oauth.signpost.exception.OAuthNotAuthorizedException;
+import oauth.signpost.http.HttpParameters;
+import oauth.signpost.http.HttpRequest;
+import oauth.signpost.http.HttpResponse;
+
+/**
+ * ABC for all provider implementations. If you're writing a custom provider,
+ * you will probably inherit from this class, since it takes a lot of work from
+ * you.
+ * 
+ * @author Matthias Kaeppler
+ */
+public abstract class AbstractOAuthProvider implements OAuthProvider {
+
+    private static final long serialVersionUID = 1L;
+
+    private String requestTokenEndpointUrl;
+
+    private String accessTokenEndpointUrl;
+
+    private String authorizationWebsiteUrl;
+
+    private HttpParameters responseParameters;
+
+    private Map<String, String> defaultHeaders;
+
+    private boolean isOAuth10a;
+
+    private transient OAuthProviderListener listener;
+
+    public AbstractOAuthProvider(String requestTokenEndpointUrl, String accessTokenEndpointUrl,
+            String authorizationWebsiteUrl) {
+        this.requestTokenEndpointUrl = requestTokenEndpointUrl;
+        this.accessTokenEndpointUrl = accessTokenEndpointUrl;
+        this.authorizationWebsiteUrl = authorizationWebsiteUrl;
+        this.responseParameters = new HttpParameters();
+        this.defaultHeaders = new HashMap<String, String>();
+    }
+
+    public String retrieveRequestToken(OAuthConsumer consumer, String callbackUrl)
+            throws OAuthMessageSignerException, OAuthNotAuthorizedException,
+            OAuthExpectationFailedException, OAuthCommunicationException {
+
+        // invalidate current credentials, if any
+        consumer.setTokenWithSecret(null, null);
+
+        // 1.0a expects the callback to be sent while getting the request token.
+        // 1.0 service providers would simply ignore this parameter.
+        retrieveToken(consumer, requestTokenEndpointUrl, OAuth.OAUTH_CALLBACK, callbackUrl);
+
+        String callbackConfirmed = responseParameters.getFirst(OAuth.OAUTH_CALLBACK_CONFIRMED);
+        responseParameters.remove(OAuth.OAUTH_CALLBACK_CONFIRMED);
+        isOAuth10a = Boolean.TRUE.toString().equals(callbackConfirmed);
+
+        // 1.0 service providers expect the callback as part of the auth URL,
+        // Do not send when 1.0a.
+        if (isOAuth10a) {
+            return OAuth.addQueryParameters(authorizationWebsiteUrl, OAuth.OAUTH_TOKEN,
+                consumer.getToken());
+        } else {
+            return OAuth.addQueryParameters(authorizationWebsiteUrl, OAuth.OAUTH_TOKEN,
+                consumer.getToken(), OAuth.OAUTH_CALLBACK, callbackUrl);
+        }
+    }
+
+    public void retrieveAccessToken(OAuthConsumer consumer, String oauthVerifier)
+            throws OAuthMessageSignerException, OAuthNotAuthorizedException,
+            OAuthExpectationFailedException, OAuthCommunicationException {
+
+        if (consumer.getToken() == null || consumer.getTokenSecret() == null) {
+            throw new OAuthExpectationFailedException(
+                    "Authorized request token or token secret not set. "
+                            + "Did you retrieve an authorized request token before?");
+        }
+
+        if (isOAuth10a && oauthVerifier != null) {
+            retrieveToken(consumer, accessTokenEndpointUrl, OAuth.OAUTH_VERIFIER, oauthVerifier);
+        } else {
+            retrieveToken(consumer, accessTokenEndpointUrl);
+        }
+    }
+
+    /**
+     * <p>
+     * Implemented by subclasses. The responsibility of this method is to
+     * contact the service provider at the given endpoint URL and fetch a
+     * request or access token. What kind of token is retrieved solely depends
+     * on the URL being used.
+     * </p>
+     * <p>
+     * Correct implementations of this method must guarantee the following
+     * post-conditions:
+     * <ul>
+     * <li>the {@link OAuthConsumer} passed to this method must have a valid
+     * {@link OAuth#OAUTH_TOKEN} and {@link OAuth#OAUTH_TOKEN_SECRET} set by
+     * calling {@link OAuthConsumer#setTokenWithSecret(String, String)}</li>
+     * <li>{@link #getResponseParameters()} must return the set of query
+     * parameters served by the service provider in the token response, with all
+     * OAuth specific parameters being removed</li>
+     * </ul>
+     * </p>
+     * 
+     * @param consumer
+     *        the {@link OAuthConsumer} that should be used to sign the request
+     * @param endpointUrl
+     *        the URL at which the service provider serves the OAuth token that
+     *        is to be fetched
+     * @param additionalParameters
+     *        you can pass parameters here (typically OAuth parameters such as
+     *        oauth_callback or oauth_verifier) which will go directly into the
+     *        signer, i.e. you don't have to put them into the request first,
+     *        just so the consumer pull them out again. Pass them sequentially
+     *        in key/value order.
+     * @throws OAuthMessageSignerException
+     *         if signing the token request fails
+     * @throws OAuthCommunicationException
+     *         if a network communication error occurs
+     * @throws OAuthNotAuthorizedException
+     *         if the server replies 401 - Unauthorized
+     * @throws OAuthExpectationFailedException
+     *         if an expectation has failed, e.g. because the server didn't
+     *         reply in the expected format
+     */
+    protected void retrieveToken(OAuthConsumer consumer, String endpointUrl,
+            String... additionalParameters) throws OAuthMessageSignerException,
+            OAuthCommunicationException, OAuthNotAuthorizedException,
+            OAuthExpectationFailedException {
+        Map<String, String> defaultHeaders = getRequestHeaders();
+
+        if (consumer.getConsumerKey() == null || consumer.getConsumerSecret() == null) {
+            throw new OAuthExpectationFailedException("Consumer key or secret not set");
+        }
+
+        HttpRequest request = null;
+        HttpResponse response = null;
+        try {
+            request = createRequest(endpointUrl);
+            for (String header : defaultHeaders.keySet()) {
+                request.setHeader(header, defaultHeaders.get(header));
+            }
+            if (additionalParameters != null) {
+                HttpParameters httpParams = new HttpParameters();
+                httpParams.putAll(additionalParameters, true);
+                consumer.setAdditionalParameters(httpParams);
+            }
+
+            if (this.listener != null) {
+                this.listener.prepareRequest(request);
+            }
+
+            consumer.sign(request);
+
+            if (this.listener != null) {
+                this.listener.prepareSubmission(request);
+            }
+
+            response = sendRequest(request);
+            int statusCode = response.getStatusCode();
+
+            boolean requestHandled = false;
+            if (this.listener != null) {
+                requestHandled = this.listener.onResponseReceived(request, response);
+            }
+            if (requestHandled) {
+                return;
+            }
+
+            if (statusCode >= 300) {
+                handleUnexpectedResponse(statusCode, response);
+            }
+
+            HttpParameters responseParams = OAuth.decodeForm(response.getContent());
+
+            String token = responseParams.getFirst(OAuth.OAUTH_TOKEN);
+            String secret = responseParams.getFirst(OAuth.OAUTH_TOKEN_SECRET);
+            responseParams.remove(OAuth.OAUTH_TOKEN);
+            responseParams.remove(OAuth.OAUTH_TOKEN_SECRET);
+
+            setResponseParameters(responseParams);
+
+            if (token == null || secret == null) {
+                throw new OAuthExpectationFailedException(
+                        "Request token or token secret not set in server reply. "
+                                + "The service provider you use is probably buggy.");
+            }
+
+            consumer.setTokenWithSecret(token, secret);
+
+        } catch (OAuthNotAuthorizedException e) {
+            throw e;
+        } catch (OAuthExpectationFailedException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new OAuthCommunicationException(e);
+        } finally {
+            try {
+                closeConnection(request, response);
+            } catch (Exception e) {
+                throw new OAuthCommunicationException(e);
+            }
+        }
+    }
+
+    protected void handleUnexpectedResponse(int statusCode, HttpResponse response) throws Exception {
+        if (response == null) {
+            return;
+        }
+        BufferedReader reader = new BufferedReader(new InputStreamReader(response.getContent()));
+        StringBuilder responseBody = new StringBuilder();
+
+        String line = reader.readLine();
+        while (line != null) {
+            responseBody.append(line);
+            line = reader.readLine();
+        }
+
+        switch (statusCode) {
+        case 401:
+            throw new OAuthNotAuthorizedException(responseBody.toString());
+        default:
+            throw new OAuthCommunicationException("Service provider responded in error: "
+                    + statusCode + " (" + response.getReasonPhrase() + ")", responseBody.toString());
+        }
+    }
+
+    /**
+     * Overrride this method if you want to customize the logic for building a
+     * request object for the given endpoint URL.
+     * 
+     * @param endpointUrl
+     *        the URL to which the request will go
+     * @return the request object
+     * @throws Exception
+     *         if something breaks
+     */
+    protected abstract HttpRequest createRequest(String endpointUrl) throws Exception;
+
+    /**
+     * Override this method if you want to customize the logic for how the given
+     * request is sent to the server.
+     * 
+     * @param request
+     *        the request to send
+     * @return the response to the request
+     * @throws Exception
+     *         if something breaks
+     */
+    protected abstract HttpResponse sendRequest(HttpRequest request) throws Exception;
+
+    /**
+     * Called when the connection is being finalized after receiving the
+     * response. Use this to do any cleanup / resource freeing.
+     * 
+     * @param request
+     *        the request that has been sent
+     * @param response
+     *        the response that has been received
+     * @throws Exception
+     *         if something breaks
+     */
+    protected void closeConnection(HttpRequest request, HttpResponse response) throws Exception {
+        // NOP
+    }
+
+    public HttpParameters getResponseParameters() {
+        return responseParameters;
+    }
+
+    /**
+     * Returns a single query parameter as served by the service provider in a
+     * token reply. You must call {@link #setResponseParameters} with the set of
+     * parameters before using this method.
+     * 
+     * @param key
+     *        the parameter name
+     * @return the parameter value
+     */
+    protected String getResponseParameter(String key) {
+        return responseParameters.getFirst(key);
+    }
+
+    public void setResponseParameters(HttpParameters parameters) {
+        this.responseParameters = parameters;
+    }
+
+    public void setOAuth10a(boolean isOAuth10aProvider) {
+        this.isOAuth10a = isOAuth10aProvider;
+    }
+
+    public boolean isOAuth10a() {
+        return isOAuth10a;
+    }
+
+    public String getRequestTokenEndpointUrl() {
+        return this.requestTokenEndpointUrl;
+    }
+
+    public String getAccessTokenEndpointUrl() {
+        return this.accessTokenEndpointUrl;
+    }
+
+    public String getAuthorizationWebsiteUrl() {
+        return this.authorizationWebsiteUrl;
+    }
+
+    public void setRequestHeader(String header, String value) {
+        defaultHeaders.put(header, value);
+    }
+
+    public Map<String, String> getRequestHeaders() {
+        return defaultHeaders;
+    }
+
+    public void setListener(OAuthProviderListener listener) {
+        this.listener = listener;
+    }
+
+    public void removeListener(OAuthProviderListener listener) {
+        this.listener = null;
+    }
+}
Index: /trunk/src/oauth/signpost/OAuth.java
===================================================================
--- /trunk/src/oauth/signpost/OAuth.java	(revision 4231)
+++ /trunk/src/oauth/signpost/OAuth.java	(revision 4231)
@@ -0,0 +1,307 @@
+/* Copyright (c) 2008, 2009 Netflix, Matthias Kaeppler
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package oauth.signpost;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.URLDecoder;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+import oauth.signpost.http.HttpParameters;
+
+import com.google.gdata.util.common.base.PercentEscaper;
+
+public class OAuth {
+
+    public static final String VERSION_1_0 = "1.0";
+
+    public static final String ENCODING = "UTF-8";
+
+    public static final String FORM_ENCODED = "application/x-www-form-urlencoded";
+
+    public static final String HTTP_AUTHORIZATION_HEADER = "Authorization";
+
+    public static final String OAUTH_CONSUMER_KEY = "oauth_consumer_key";
+
+    public static final String OAUTH_TOKEN = "oauth_token";
+
+    public static final String OAUTH_TOKEN_SECRET = "oauth_token_secret";
+
+    public static final String OAUTH_SIGNATURE_METHOD = "oauth_signature_method";
+
+    public static final String OAUTH_SIGNATURE = "oauth_signature";
+
+    public static final String OAUTH_TIMESTAMP = "oauth_timestamp";
+
+    public static final String OAUTH_NONCE = "oauth_nonce";
+
+    public static final String OAUTH_VERSION = "oauth_version";
+
+    public static final String OAUTH_CALLBACK = "oauth_callback";
+
+    public static final String OAUTH_CALLBACK_CONFIRMED = "oauth_callback_confirmed";
+
+    public static final String OAUTH_VERIFIER = "oauth_verifier";
+
+    /**
+     * Pass this value as the callback "url" upon retrieving a request token if
+     * your application cannot receive callbacks (e.g. because it's a desktop
+     * app). This will tell the service provider that verification happens
+     * out-of-band, which basically means that it will generate a PIN code (the
+     * OAuth verifier) and display that to your user. You must obtain this code
+     * from your user and pass it to
+     * {@link OAuthProvider#retrieveAccessToken(OAuthConsumer, String)} in order
+     * to complete the token handshake.
+     */
+    public static final String OUT_OF_BAND = "oob";
+
+    private static final PercentEscaper percentEncoder = new PercentEscaper(
+            "-._~", false);
+
+    public static String percentEncode(String s) {
+        if (s == null) {
+            return "";
+        }
+        return percentEncoder.escape(s);
+    }
+
+    public static String percentDecode(String s) {
+        try {
+            if (s == null) {
+                return "";
+            }
+            return URLDecoder.decode(s, ENCODING);
+            // This implements http://oauth.pbwiki.com/FlexibleDecoding
+        } catch (java.io.UnsupportedEncodingException wow) {
+            throw new RuntimeException(wow.getMessage(), wow);
+        }
+    }
+
+    /**
+     * Construct a x-www-form-urlencoded document containing the given sequence
+     * of name/value pairs. Use OAuth percent encoding (not exactly the encoding
+     * mandated by x-www-form-urlencoded).
+     */
+    public static <T extends Map.Entry<String, String>> void formEncode(Collection<T> parameters,
+            OutputStream into) throws IOException {
+        if (parameters != null) {
+            boolean first = true;
+            for (Map.Entry<String, String> entry : parameters) {
+                if (first) {
+                    first = false;
+                } else {
+                    into.write('&');
+                }
+                into.write(percentEncode(safeToString(entry.getKey())).getBytes());
+                into.write('=');
+                into.write(percentEncode(safeToString(entry.getValue())).getBytes());
+            }
+        }
+    }
+
+    /**
+     * Construct a x-www-form-urlencoded document containing the given sequence
+     * of name/value pairs. Use OAuth percent encoding (not exactly the encoding
+     * mandated by x-www-form-urlencoded).
+     */
+    public static <T extends Map.Entry<String, String>> String formEncode(Collection<T> parameters)
+            throws IOException {
+        ByteArrayOutputStream b = new ByteArrayOutputStream();
+        formEncode(parameters, b);
+        return new String(b.toByteArray());
+    }
+
+    /** Parse a form-urlencoded document. */
+    public static HttpParameters decodeForm(String form) {
+        HttpParameters params = new HttpParameters();
+        if (isEmpty(form)) {
+            return params;
+        }
+        for (String nvp : form.split("\\&")) {
+            int equals = nvp.indexOf('=');
+            String name;
+            String value;
+            if (equals < 0) {
+                name = percentDecode(nvp);
+                value = null;
+            } else {
+                name = percentDecode(nvp.substring(0, equals));
+                value = percentDecode(nvp.substring(equals + 1));
+            }
+
+            params.put(name, value);
+        }
+        return params;
+    }
+
+    public static HttpParameters decodeForm(InputStream content)
+            throws IOException {
+        BufferedReader reader = new BufferedReader(new InputStreamReader(
+                content));
+        StringBuilder sb = new StringBuilder();
+        String line = reader.readLine();
+        while (line != null) {
+            sb.append(line);
+            line = reader.readLine();
+        }
+
+        return decodeForm(sb.toString());
+    }
+
+    /**
+     * Construct a Map containing a copy of the given parameters. If several
+     * parameters have the same name, the Map will contain the first value,
+     * only.
+     */
+    public static <T extends Map.Entry<String, String>> Map<String, String> toMap(Collection<T> from) {
+        HashMap<String, String> map = new HashMap<String, String>();
+        if (from != null) {
+            for (Map.Entry<String, String> entry : from) {
+                String key = entry.getKey();
+                if (!map.containsKey(key)) {
+                    map.put(key, entry.getValue());
+                }
+            }
+        }
+        return map;
+    }
+
+    public static final String safeToString(Object from) {
+        return (from == null) ? null : from.toString();
+    }
+
+    public static boolean isEmpty(String str) {
+        return (str == null) || (str.length() == 0);
+    }
+
+    /**
+     * Appends a list of key/value pairs to the given URL, e.g.:
+     * 
+     * <pre>
+     * String url = OAuth.addQueryParameters(&quot;http://example.com?a=1&quot;, b, 2, c, 3);
+     * </pre>
+     * 
+     * which yields:
+     * 
+     * <pre>
+     * http://example.com?a=1&b=2&c=3
+     * </pre>
+     * 
+     * All parameters will be encoded according to OAuth's percent encoding
+     * rules.
+     * 
+     * @param url
+     *        the URL
+     * @param kvPairs
+     *        the list of key/value pairs
+     * @return
+     */
+    public static String addQueryParameters(String url, String... kvPairs) {
+        String queryDelim = url.contains("?") ? "&" : "?";
+        StringBuilder sb = new StringBuilder(url + queryDelim);
+        for (int i = 0; i < kvPairs.length; i += 2) {
+            if (i > 0) {
+                sb.append("&");
+            }
+            sb.append(OAuth.percentEncode(kvPairs[i]) + "="
+                    + OAuth.percentEncode(kvPairs[i + 1]));
+        }
+        return sb.toString();
+    }
+
+    public static String addQueryParameters(String url, Map<String, String> params) {
+        String[] kvPairs = new String[params.size() * 2];
+        int idx = 0;
+        for (String key : params.keySet()) {
+            kvPairs[idx] = key;
+            kvPairs[idx + 1] = params.get(key);
+            idx += 2;
+        }
+        return addQueryParameters(url, kvPairs);
+    }
+
+    /**
+     * Builds an OAuth header from the given list of header fields. All
+     * parameters starting in 'oauth_*' will be percent encoded.
+     * 
+     * <pre>
+     * String authHeader = OAuth.prepareOAuthHeader(&quot;realm&quot;, &quot;http://example.com&quot;, &quot;oauth_token&quot;, &quot;x%y&quot;);
+     * </pre>
+     * 
+     * which yields:
+     * 
+     * <pre>
+     * OAuth realm="http://example.com", oauth_token="x%25y"
+     * </pre>
+     * 
+     * @param kvPairs
+     *        the list of key/value pairs
+     * @return a string eligible to be used as an OAuth HTTP Authorization
+     *         header.
+     */
+    public static String prepareOAuthHeader(String... kvPairs) {
+        StringBuilder sb = new StringBuilder("OAuth ");
+        for (int i = 0; i < kvPairs.length; i += 2) {
+            if (i > 0) {
+                sb.append(", ");
+            }
+            String value = kvPairs[i].startsWith("oauth_") ? OAuth
+                .percentEncode(kvPairs[i + 1]) : kvPairs[i + 1];
+            sb.append(OAuth.percentEncode(kvPairs[i]) + "=\"" + value + "\"");
+        }
+        return sb.toString();
+    }
+
+    public static HttpParameters oauthHeaderToParamsMap(String oauthHeader) {
+        HttpParameters params = new HttpParameters();
+        if (oauthHeader == null || !oauthHeader.startsWith("OAuth ")) {
+            return params;
+        }
+        oauthHeader = oauthHeader.substring("OAuth ".length());
+        String[] elements = oauthHeader.split(",");
+        for (String keyValuePair : elements) {
+            String[] keyValue = keyValuePair.split("=");
+            params.put(keyValue[0].trim(), keyValue[1].replace("\"", "").trim());
+        }
+        return params;
+    }
+
+    /**
+     * Helper method to concatenate a parameter and its value to a pair that can
+     * be used in an HTTP header. This method percent encodes both parts before
+     * joining them.
+     * 
+     * @param name
+     *        the OAuth parameter name, e.g. oauth_token
+     * @param value
+     *        the OAuth parameter value, e.g. 'hello oauth'
+     * @return a name/value pair, e.g. oauth_token="hello%20oauth"
+     */
+    public static String toHeaderElement(String name, String value) {
+        return OAuth.percentEncode(name) + "=\"" + OAuth.percentEncode(value) + "\"";
+    }
+
+    public static void debugOut(String key, String value) {
+        if (System.getProperty("debug") != null) {
+            System.out.println("[SIGNPOST] " + key + ": " + value);
+        }
+    }
+}
Index: /trunk/src/oauth/signpost/OAuthConsumer.java
===================================================================
--- /trunk/src/oauth/signpost/OAuthConsumer.java	(revision 4231)
+++ /trunk/src/oauth/signpost/OAuthConsumer.java	(revision 4231)
@@ -0,0 +1,193 @@
+/* Copyright (c) 2009 Matthias Kaeppler
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package oauth.signpost;
+
+import java.io.Serializable;
+
+import oauth.signpost.exception.OAuthCommunicationException;
+import oauth.signpost.exception.OAuthExpectationFailedException;
+import oauth.signpost.exception.OAuthMessageSignerException;
+import oauth.signpost.http.HttpParameters;
+import oauth.signpost.http.HttpRequest;
+import oauth.signpost.signature.AuthorizationHeaderSigningStrategy;
+import oauth.signpost.signature.HmacSha1MessageSigner;
+import oauth.signpost.signature.OAuthMessageSigner;
+import oauth.signpost.signature.PlainTextMessageSigner;
+import oauth.signpost.signature.QueryStringSigningStrategy;
+import oauth.signpost.signature.SigningStrategy;
+
+/**
+ * <p>
+ * Exposes a simple interface to sign HTTP requests using a given OAuth token
+ * and secret. Refer to {@link OAuthProvider} how to retrieve a valid token and
+ * token secret.
+ * </p>
+ * <p>
+ * HTTP messages are signed as follows:
+ * <p>
+ * 
+ * <pre>
+ * // exchange the arguments with the actual token/secret pair
+ * OAuthConsumer consumer = new DefaultOAuthConsumer(&quot;1234&quot;, &quot;5678&quot;);
+ * 
+ * URL url = new URL(&quot;http://example.com/protected.xml&quot;);
+ * HttpURLConnection request = (HttpURLConnection) url.openConnection();
+ * 
+ * consumer.sign(request);
+ * 
+ * request.connect();
+ * </pre>
+ * 
+ * </p>
+ * </p>
+ * 
+ * @author Matthias Kaeppler
+ */
+public interface OAuthConsumer extends Serializable {
+
+    /**
+     * Sets the message signer that should be used to generate the OAuth
+     * signature.
+     * 
+     * @param messageSigner
+     *        the signer
+     * @see HmacSha1MessageSigner
+     * @see PlainTextMessageSigner
+     */
+    public void setMessageSigner(OAuthMessageSigner messageSigner);
+
+    /**
+     * Allows you to add parameters (typically OAuth parameters such as
+     * oauth_callback or oauth_verifier) which will go directly into the signer,
+     * i.e. you don't have to put them into the request first. The consumer's
+     * {@link SigningStrategy} will then take care of writing them to the
+     * correct part of the request before it is sent. Note that these parameters
+     * are expected to already be percent encoded -- they will be simply merged
+     * as-is.
+     * 
+     * @param additionalParameters
+     *        the parameters
+     */
+    public void setAdditionalParameters(HttpParameters additionalParameters);
+
+    /**
+     * Defines which strategy should be used to write a signature to an HTTP
+     * request.
+     * 
+     * @param signingStrategy
+     *        the strategy
+     * @see AuthorizationHeaderSigningStrategy
+     * @see QueryStringSigningStrategy
+     */
+    public void setSigningStrategy(SigningStrategy signingStrategy);
+
+    /**
+     * <p>
+     * Causes the consumer to always include the oauth_token parameter to be
+     * sent, even if blank. If you're seeing 401s during calls to
+     * {@link OAuthProvider#retrieveRequestToken}, try setting this to true.
+     * </p>
+     * 
+     * @param enable
+     *        true or false
+     */
+    public void setSendEmptyTokens(boolean enable);
+
+    /**
+     * Signs the given HTTP request by writing an OAuth signature (and other
+     * required OAuth parameters) to it. Where these parameters are written
+     * depends on the current {@link SigningStrategy}.
+     * 
+     * @param request
+     *        the request to sign
+     * @return the request object passed as an argument
+     * @throws OAuthMessageSignerException
+     * @throws OAuthExpectationFailedException
+     * @throws OAuthCommunicationException
+     */
+    public HttpRequest sign(HttpRequest request) throws OAuthMessageSignerException,
+            OAuthExpectationFailedException, OAuthCommunicationException;
+
+    /**
+     * <p>
+     * Signs the given HTTP request by writing an OAuth signature (and other
+     * required OAuth parameters) to it. Where these parameters are written
+     * depends on the current {@link SigningStrategy}.
+     * </p>
+     * This method accepts HTTP library specific request objects; the consumer
+     * implementation must ensure that only those request types are passed which
+     * it supports.
+     * 
+     * @param request
+     *        the request to sign
+     * @return the request object passed as an argument
+     * @throws OAuthMessageSignerException
+     * @throws OAuthExpectationFailedException
+     * @throws OAuthCommunicationException
+     */
+    public HttpRequest sign(Object request) throws OAuthMessageSignerException,
+            OAuthExpectationFailedException, OAuthCommunicationException;
+
+    /**
+     * <p>
+     * "Signs" the given URL by appending all OAuth parameters to it which are
+     * required for message signing. The assumed HTTP method is GET.
+     * Essentially, this is equivalent to signing an HTTP GET request, but it
+     * can be useful if your application requires clickable links to protected
+     * resources, i.e. when your application does not have access to the actual
+     * request that is being sent.
+     * </p>
+     * 
+     * @param url
+     *        the input URL. May have query parameters.
+     * @return the input URL, with all necessary OAuth parameters attached as a
+     *         query string. Existing query parameters are preserved.
+     * @throws OAuthMessageSignerException
+     * @throws OAuthExpectationFailedException
+     * @throws OAuthCommunicationException
+     */
+    public String sign(String url) throws OAuthMessageSignerException,
+            OAuthExpectationFailedException, OAuthCommunicationException;
+
+    /**
+     * Sets the OAuth token and token secret used for message signing.
+     * 
+     * @param token
+     *        the token
+     * @param tokenSecret
+     *        the token secret
+     */
+    public void setTokenWithSecret(String token, String tokenSecret);
+
+    public String getToken();
+
+    public String getTokenSecret();
+
+    public String getConsumerKey();
+
+    public String getConsumerSecret();
+
+    /**
+     * Returns all parameters collected from the HTTP request during message
+     * signing (this means the return value may be NULL before a call to
+     * {@link #sign}), plus all required OAuth parameters that were added
+     * because the request didn't contain them beforehand. In other words, this
+     * is the exact set of parameters that were used for creating the message
+     * signature.
+     * 
+     * @return the request parameters used for message signing
+     */
+    public HttpParameters getRequestParameters();
+}
Index: /trunk/src/oauth/signpost/OAuthProvider.java
===================================================================
--- /trunk/src/oauth/signpost/OAuthProvider.java	(revision 4231)
+++ /trunk/src/oauth/signpost/OAuthProvider.java	(revision 4231)
@@ -0,0 +1,236 @@
+/*
+ * Copyright (c) 2009 Matthias Kaeppler Licensed under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law
+ * or agreed to in writing, software distributed under the License is
+ * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+package oauth.signpost;
+
+import java.io.Serializable;
+import java.util.Map;
+
+import oauth.signpost.basic.DefaultOAuthConsumer;
+import oauth.signpost.basic.DefaultOAuthProvider;
+import oauth.signpost.exception.OAuthCommunicationException;
+import oauth.signpost.exception.OAuthExpectationFailedException;
+import oauth.signpost.exception.OAuthMessageSignerException;
+import oauth.signpost.exception.OAuthNotAuthorizedException;
+import oauth.signpost.http.HttpParameters;
+
+/**
+ * <p>
+ * Supplies an interface that can be used to retrieve request and access tokens
+ * from an OAuth 1.0(a) service provider. A provider object requires an
+ * {@link OAuthConsumer} to sign the token request message; after a token has
+ * been retrieved, the consumer is automatically updated with the token and the
+ * corresponding secret.
+ * </p>
+ * <p>
+ * To initiate the token exchange, create a new provider instance and configure
+ * it with the URLs the service provider exposes for requesting tokens and
+ * resource authorization, e.g.:
+ * </p>
+ * 
+ * <pre>
+ * OAuthProvider provider = new DefaultOAuthProvider(&quot;http://twitter.com/oauth/request_token&quot;,
+ *         &quot;http://twitter.com/oauth/access_token&quot;, &quot;http://twitter.com/oauth/authorize&quot;);
+ * </pre>
+ * <p>
+ * Depending on the HTTP library you use, you may need a different provider
+ * type, refer to the website documentation for how to do that.
+ * </p>
+ * <p>
+ * To receive a request token which the user must authorize, you invoke it using
+ * a consumer instance and a callback URL:
+ * </p>
+ * <p>
+ * 
+ * <pre>
+ * String url = provider.retrieveRequestToken(consumer, &quot;http://www.example.com/callback&quot;);
+ * </pre>
+ * 
+ * </p>
+ * <p>
+ * That url must be opened in a Web browser, where the user can grant access to
+ * the resources in question. If that succeeds, the service provider will
+ * redirect to the callback URL and append the blessed request token.
+ * </p>
+ * <p>
+ * That token must now be exchanged for an access token, as such:
+ * </p>
+ * <p>
+ * 
+ * <pre>
+ * provider.retrieveAccessToken(consumer, nullOrVerifierCode);
+ * </pre>
+ * 
+ * </p>
+ * <p>
+ * where nullOrVerifierCode is either null if your provided a callback URL in
+ * the previous step, or the pin code issued by the service provider to the user
+ * if the request was out-of-band (cf. {@link OAuth#OUT_OF_BAND}.
+ * </p>
+ * <p>
+ * The consumer used during token handshakes is now ready for signing.
+ * </p>
+ * 
+ * @see DefaultOAuthProvider
+ * @see DefaultOAuthConsumer
+ * @see OAuthProviderListener
+ */
+public interface OAuthProvider extends Serializable {
+
+    /**
+     * Queries the service provider for a request token.
+     * <p>
+     * <b>Pre-conditions:</b> the given {@link OAuthConsumer} must have a valid
+     * consumer key and consumer secret already set.
+     * </p>
+     * <p>
+     * <b>Post-conditions:</b> the given {@link OAuthConsumer} will have an
+     * unauthorized request token and token secret set.
+     * </p>
+     * 
+     * @param consumer
+     *        the {@link OAuthConsumer} that should be used to sign the request
+     * @param callbackUrl
+     *        Pass an actual URL if your app can receive callbacks and you want
+     *        to get informed about the result of the authorization process.
+     *        Pass {@link OAuth.OUT_OF_BAND} if the service provider implements
+     *        OAuth 1.0a and your app cannot receive callbacks. Pass null if the
+     *        service provider implements OAuth 1.0 and your app cannot receive
+     *        callbacks. Please note that some services (among them Twitter)
+     *        will fail authorization if you pass a callback URL but register
+     *        your application as a desktop app (which would only be able to
+     *        handle OOB requests).
+     * @return The URL to which the user must be sent in order to authorize the
+     *         consumer. It includes the unauthorized request token (and in the
+     *         case of OAuth 1.0, the callback URL -- 1.0a clients send along
+     *         with the token request).
+     * @throws OAuthMessageSignerException
+     *         if signing the request failed
+     * @throws OAuthNotAuthorizedException
+     *         if the service provider rejected the consumer
+     * @throws OAuthExpectationFailedException
+     *         if required parameters were not correctly set by the consumer or
+     *         service provider
+     * @throws OAuthCommunicationException
+     *         if server communication failed
+     */
+    public String retrieveRequestToken(OAuthConsumer consumer, String callbackUrl)
+            throws OAuthMessageSignerException, OAuthNotAuthorizedException,
+            OAuthExpectationFailedException, OAuthCommunicationException;
+
+    /**
+     * Queries the service provider for an access token.
+     * <p>
+     * <b>Pre-conditions:</b> the given {@link OAuthConsumer} must have a valid
+     * consumer key, consumer secret, authorized request token and token secret
+     * already set.
+     * </p>
+     * <p>
+     * <b>Post-conditions:</b> the given {@link OAuthConsumer} will have an
+     * access token and token secret set.
+     * </p>
+     * 
+     * @param consumer
+     *        the {@link OAuthConsumer} that should be used to sign the request
+     * @param oauthVerifier
+     *        <b>NOTE: Only applies to service providers implementing OAuth
+     *        1.0a. Set to null if the service provider is still using OAuth
+     *        1.0.</b> The verification code issued by the service provider
+     *        after the the user has granted the consumer authorization. If the
+     *        callback method provided in the previous step was
+     *        {@link OAuth.OUT_OF_BAND}, then you must ask the user for this
+     *        value. If your app has received a callback, the verfication code
+     *        was passed as part of that request instead.
+     * @throws OAuthMessageSignerException
+     *         if signing the request failed
+     * @throws OAuthNotAuthorizedException
+     *         if the service provider rejected the consumer
+     * @throws OAuthExpectationFailedException
+     *         if required parameters were not correctly set by the consumer or
+     *         service provider
+     * @throws OAuthCommunicationException
+     *         if server communication failed
+     */
+    public void retrieveAccessToken(OAuthConsumer consumer, String oauthVerifier)
+            throws OAuthMessageSignerException, OAuthNotAuthorizedException,
+            OAuthExpectationFailedException, OAuthCommunicationException;
+
+    /**
+     * Any additional non-OAuth parameters returned in the response body of a
+     * token request can be obtained through this method. These parameters will
+     * be preserved until the next token request is issued. The return value is
+     * never null.
+     */
+    public HttpParameters getResponseParameters();
+
+    /**
+     * Subclasses must use this setter to preserve any non-OAuth query
+     * parameters contained in the server response. It's the caller's
+     * responsibility that any OAuth parameters be removed beforehand.
+     * 
+     * @param parameters
+     *        the map of query parameters served by the service provider in the
+     *        token response
+     */
+    public void setResponseParameters(HttpParameters parameters);
+
+    /**
+     * Use this method to set custom HTTP headers to be used for the requests
+     * which are sent to retrieve tokens. @deprecated THIS METHOD HAS BEEN
+     * DEPRECATED. Use {@link OAuthProviderListener} to customize requests.
+     * 
+     * @param header
+     *        The header name (e.g. 'WWW-Authenticate')
+     * @param value
+     *        The header value (e.g. 'realm=www.example.com')
+     */
+    @Deprecated
+    public void setRequestHeader(String header, String value);
+
+    /**
+     * @deprecated THIS METHOD HAS BEEN DEPRECATED. Use
+     *             {@link OAuthProviderListener} to customize requests.
+     * @return all request headers set via {@link #setRequestHeader}
+     */
+    @Deprecated
+    public Map<String, String> getRequestHeaders();
+
+    /**
+     * @param isOAuth10aProvider
+     *        set to true if the service provider supports OAuth 1.0a. Note that
+     *        you need only call this method if you reconstruct a provider
+     *        object in between calls to retrieveRequestToken() and
+     *        retrieveAccessToken() (i.e. if the object state isn't preserved).
+     *        If instead those two methods are called on the same provider
+     *        instance, this flag will be deducted automatically based on the
+     *        server response during retrieveRequestToken(), so you can simply
+     *        ignore this method.
+     */
+    public void setOAuth10a(boolean isOAuth10aProvider);
+
+    /**
+     * @return true if the service provider supports OAuth 1.0a. Note that the
+     *         value returned here is only meaningful after you have already
+     *         performed the token handshake, otherwise there is no way to
+     *         determine what version of the OAuth protocol the service provider
+     *         implements.
+     */
+    public boolean isOAuth10a();
+
+    public String getRequestTokenEndpointUrl();
+
+    public String getAccessTokenEndpointUrl();
+
+    public String getAuthorizationWebsiteUrl();
+
+    public void setListener(OAuthProviderListener listener);
+
+    public void removeListener(OAuthProviderListener listener);
+}
Index: /trunk/src/oauth/signpost/OAuthProviderListener.java
===================================================================
--- /trunk/src/oauth/signpost/OAuthProviderListener.java	(revision 4231)
+++ /trunk/src/oauth/signpost/OAuthProviderListener.java	(revision 4231)
@@ -0,0 +1,48 @@
+package oauth.signpost;
+
+import oauth.signpost.http.HttpRequest;
+import oauth.signpost.http.HttpResponse;
+
+/**
+ * Provides hooks into the token request handling procedure executed by
+ * {@link OAuthProvider}.
+ * 
+ * @author Matthias Kaeppler
+ */
+public interface OAuthProviderListener {
+
+    /**
+     * Called after the request has been created and default headers added, but
+     * before the request has been signed.
+     * 
+     * @param request
+     *        the request to be sent
+     * @throws Exception
+     */
+    void prepareRequest(HttpRequest request) throws Exception;
+
+    /**
+     * Called after the request has been signed, but before it's being sent.
+     * 
+     * @param request
+     *        the request to be sent
+     * @throws Exception
+     */
+    void prepareSubmission(HttpRequest request) throws Exception;
+
+    /**
+     * Called when the server response has been received. You can implement this
+     * to manually handle the response data.
+     * 
+     * @param request
+     *        the request that was sent
+     * @param response
+     *        the response that was received
+     * @return returning true means you have handled the response, and the
+     *         provider will return immediately. Return false to let the event
+     *         propagate and let the provider execute its default response
+     *         handling.
+     * @throws Exception
+     */
+    boolean onResponseReceived(HttpRequest request, HttpResponse response) throws Exception;
+}
Index: /trunk/src/oauth/signpost/basic/DefaultOAuthConsumer.java
===================================================================
--- /trunk/src/oauth/signpost/basic/DefaultOAuthConsumer.java	(revision 4231)
+++ /trunk/src/oauth/signpost/basic/DefaultOAuthConsumer.java	(revision 4231)
@@ -0,0 +1,45 @@
+/* Copyright (c) 2009 Matthias Kaeppler
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package oauth.signpost.basic;
+
+import java.net.HttpURLConnection;
+
+import oauth.signpost.AbstractOAuthConsumer;
+import oauth.signpost.http.HttpRequest;
+
+/**
+ * The default implementation for an OAuth consumer. Only supports signing
+ * {@link java.net.HttpURLConnection} type requests.
+ * 
+ * @author Matthias Kaeppler
+ */
+public class DefaultOAuthConsumer extends AbstractOAuthConsumer {
+
+    private static final long serialVersionUID = 1L;
+
+    public DefaultOAuthConsumer(String consumerKey, String consumerSecret) {
+        super(consumerKey, consumerSecret);
+    }
+
+    @Override
+    protected HttpRequest wrap(Object request) {
+        if (!(request instanceof HttpURLConnection)) {
+            throw new IllegalArgumentException(
+                    "The default consumer expects requests of type java.net.HttpURLConnection");
+        }
+        return new HttpURLConnectionRequestAdapter((HttpURLConnection) request);
+    }
+
+}
Index: /trunk/src/oauth/signpost/basic/DefaultOAuthProvider.java
===================================================================
--- /trunk/src/oauth/signpost/basic/DefaultOAuthProvider.java	(revision 4231)
+++ /trunk/src/oauth/signpost/basic/DefaultOAuthProvider.java	(revision 4231)
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2009 Matthias Kaeppler Licensed under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law
+ * or agreed to in writing, software distributed under the License is
+ * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+package oauth.signpost.basic;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import oauth.signpost.AbstractOAuthProvider;
+import oauth.signpost.http.HttpRequest;
+import oauth.signpost.http.HttpResponse;
+
+/**
+ * This default implementation uses {@link java.net.HttpURLConnection} type GET
+ * requests to receive tokens from a service provider.
+ * 
+ * @author Matthias Kaeppler
+ */
+public class DefaultOAuthProvider extends AbstractOAuthProvider {
+
+    private static final long serialVersionUID = 1L;
+
+    public DefaultOAuthProvider(String requestTokenEndpointUrl, String accessTokenEndpointUrl,
+            String authorizationWebsiteUrl) {
+        super(requestTokenEndpointUrl, accessTokenEndpointUrl, authorizationWebsiteUrl);
+    }
+
+    protected HttpRequest createRequest(String endpointUrl) throws MalformedURLException,
+            IOException {
+        HttpURLConnection connection = (HttpURLConnection) new URL(endpointUrl).openConnection();
+        connection.setRequestMethod("POST");
+        connection.setAllowUserInteraction(false);
+        connection.setRequestProperty("Content-Length", "0");
+        return new HttpURLConnectionRequestAdapter(connection);
+    }
+
+    protected HttpResponse sendRequest(HttpRequest request) throws IOException {
+        HttpURLConnection connection = (HttpURLConnection) request.unwrap();
+        connection.connect();
+        return new HttpURLConnectionResponseAdapter(connection);
+    }
+
+    @Override
+    protected void closeConnection(HttpRequest request, HttpResponse response) {
+        HttpURLConnection connection = (HttpURLConnection) request.unwrap();
+        if (connection != null) {
+            connection.disconnect();
+        }
+    }
+}
Index: /trunk/src/oauth/signpost/basic/HttpURLConnectionRequestAdapter.java
===================================================================
--- /trunk/src/oauth/signpost/basic/HttpURLConnectionRequestAdapter.java	(revision 4231)
+++ /trunk/src/oauth/signpost/basic/HttpURLConnectionRequestAdapter.java	(revision 4231)
@@ -0,0 +1,63 @@
+package oauth.signpost.basic;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import oauth.signpost.http.HttpRequest;
+
+public class HttpURLConnectionRequestAdapter implements HttpRequest {
+
+    protected HttpURLConnection connection;
+
+    public HttpURLConnectionRequestAdapter(HttpURLConnection connection) {
+        this.connection = connection;
+    }
+
+    public String getMethod() {
+        return connection.getRequestMethod();
+    }
+
+    public String getRequestUrl() {
+        return connection.getURL().toExternalForm();
+    }
+
+    public void setRequestUrl(String url) {
+        // can't do
+    }
+
+    public void setHeader(String name, String value) {
+        connection.setRequestProperty(name, value);
+    }
+
+    public String getHeader(String name) {
+        return connection.getRequestProperty(name);
+    }
+
+    public Map<String, String> getAllHeaders() {
+        Map<String, List<String>> origHeaders = connection.getRequestProperties();
+        Map<String, String> headers = new HashMap<String, String>(origHeaders.size());
+        for (String name : origHeaders.keySet()) {
+            List<String> values = origHeaders.get(name);
+            if (!values.isEmpty()) {
+                headers.put(name, values.get(0));
+            }
+        }
+        return headers;
+    }
+
+    public InputStream getMessagePayload() throws IOException {
+        return null;
+    }
+
+    public String getContentType() {
+        return connection.getRequestProperty("Content-Type");
+    }
+
+    public HttpURLConnection unwrap() {
+        return connection;
+    }
+}
Index: /trunk/src/oauth/signpost/basic/HttpURLConnectionResponseAdapter.java
===================================================================
--- /trunk/src/oauth/signpost/basic/HttpURLConnectionResponseAdapter.java	(revision 4231)
+++ /trunk/src/oauth/signpost/basic/HttpURLConnectionResponseAdapter.java	(revision 4231)
@@ -0,0 +1,32 @@
+package oauth.signpost.basic;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+
+import oauth.signpost.http.HttpResponse;
+
+public class HttpURLConnectionResponseAdapter implements HttpResponse {
+
+    private HttpURLConnection connection;
+
+    public HttpURLConnectionResponseAdapter(HttpURLConnection connection) {
+        this.connection = connection;
+    }
+
+    public InputStream getContent() throws IOException {
+        return connection.getInputStream();
+    }
+
+    public int getStatusCode() throws IOException {
+        return connection.getResponseCode();
+    }
+
+    public String getReasonPhrase() throws Exception {
+        return connection.getResponseMessage();
+    }
+
+    public Object unwrap() {
+        return connection;
+    }
+}
Index: /trunk/src/oauth/signpost/basic/UrlStringRequestAdapter.java
===================================================================
--- /trunk/src/oauth/signpost/basic/UrlStringRequestAdapter.java	(revision 4231)
+++ /trunk/src/oauth/signpost/basic/UrlStringRequestAdapter.java	(revision 4231)
@@ -0,0 +1,52 @@
+package oauth.signpost.basic;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.Map;
+
+import oauth.signpost.http.HttpRequest;
+
+public class UrlStringRequestAdapter implements HttpRequest {
+
+    private String url;
+
+    public UrlStringRequestAdapter(String url) {
+        this.url = url;
+    }
+
+    public String getMethod() {
+        return "GET";
+    }
+
+    public String getRequestUrl() {
+        return url;
+    }
+
+    public void setRequestUrl(String url) {
+        this.url = url;
+    }
+
+    public void setHeader(String name, String value) {
+    }
+
+    public String getHeader(String name) {
+        return null;
+    }
+
+    public Map<String, String> getAllHeaders() {
+        return Collections.emptyMap();
+    }
+
+    public InputStream getMessagePayload() throws IOException {
+        return null;
+    }
+
+    public String getContentType() {
+        return null;
+    }
+
+    public Object unwrap() {
+        return url;
+    }
+}
Index: /trunk/src/oauth/signpost/exception/OAuthCommunicationException.java
===================================================================
--- /trunk/src/oauth/signpost/exception/OAuthCommunicationException.java	(revision 4231)
+++ /trunk/src/oauth/signpost/exception/OAuthCommunicationException.java	(revision 4231)
@@ -0,0 +1,35 @@
+/* Copyright (c) 2009 Matthias Kaeppler
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package oauth.signpost.exception;
+
+@SuppressWarnings("serial")
+public class OAuthCommunicationException extends OAuthException {
+
+    private String responseBody;
+    
+    public OAuthCommunicationException(Exception cause) {
+        super("Communication with the service provider failed: "
+                + cause.getLocalizedMessage(), cause);
+    }
+    
+    public OAuthCommunicationException(String message, String responseBody) {
+        super(message);
+        this.responseBody = responseBody;
+    }
+    
+    public String getResponseBody() {
+        return responseBody;
+    }
+}
Index: /trunk/src/oauth/signpost/exception/OAuthException.java
===================================================================
--- /trunk/src/oauth/signpost/exception/OAuthException.java	(revision 4231)
+++ /trunk/src/oauth/signpost/exception/OAuthException.java	(revision 4231)
@@ -0,0 +1,17 @@
+package oauth.signpost.exception;
+
+@SuppressWarnings("serial")
+public abstract class OAuthException extends Exception {
+
+    public OAuthException(String message) {
+        super(message);
+    }
+
+    public OAuthException(Throwable cause) {
+        super(cause);
+    }
+
+    public OAuthException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
Index: /trunk/src/oauth/signpost/exception/OAuthExpectationFailedException.java
===================================================================
--- /trunk/src/oauth/signpost/exception/OAuthExpectationFailedException.java	(revision 4231)
+++ /trunk/src/oauth/signpost/exception/OAuthExpectationFailedException.java	(revision 4231)
@@ -0,0 +1,23 @@
+/* Copyright (c) 2009 Matthias Kaeppler
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package oauth.signpost.exception;
+
+@SuppressWarnings("serial")
+public class OAuthExpectationFailedException extends OAuthException {
+
+    public OAuthExpectationFailedException(String message) {
+        super(message);
+    }
+}
Index: /trunk/src/oauth/signpost/exception/OAuthMessageSignerException.java
===================================================================
--- /trunk/src/oauth/signpost/exception/OAuthMessageSignerException.java	(revision 4231)
+++ /trunk/src/oauth/signpost/exception/OAuthMessageSignerException.java	(revision 4231)
@@ -0,0 +1,28 @@
+/* Copyright (c) 2009 Matthias Kaeppler
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package oauth.signpost.exception;
+
+@SuppressWarnings("serial")
+public class OAuthMessageSignerException extends OAuthException {
+
+    public OAuthMessageSignerException(String message) {
+        super(message);
+    }
+
+    public OAuthMessageSignerException(Exception cause) {
+        super(cause);
+    }
+
+}
Index: /trunk/src/oauth/signpost/exception/OAuthNotAuthorizedException.java
===================================================================
--- /trunk/src/oauth/signpost/exception/OAuthNotAuthorizedException.java	(revision 4231)
+++ /trunk/src/oauth/signpost/exception/OAuthNotAuthorizedException.java	(revision 4231)
@@ -0,0 +1,38 @@
+/* Copyright (c) 2009 Matthias Kaeppler
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package oauth.signpost.exception;
+
+@SuppressWarnings("serial")
+public class OAuthNotAuthorizedException extends OAuthException {
+
+    private static final String ERROR = "Authorization failed (server replied with a 401). "
+        + "This can happen if the consumer key was not correct or "
+        + "the signatures did not match.";
+    
+    private String responseBody;
+    
+    public OAuthNotAuthorizedException() {
+        super(ERROR);
+    }
+    
+    public OAuthNotAuthorizedException(String responseBody) {
+        super(ERROR);
+        this.responseBody = responseBody;
+    }
+    
+    public String getResponseBody() {
+        return responseBody;
+    }
+}
Index: /trunk/src/oauth/signpost/http/HttpParameters.java
===================================================================
--- /trunk/src/oauth/signpost/http/HttpParameters.java	(revision 4231)
+++ /trunk/src/oauth/signpost/http/HttpParameters.java	(revision 4231)
@@ -0,0 +1,270 @@
+/* Copyright (c) 2008, 2009 Netflix, Matthias Kaeppler
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package oauth.signpost.http;
+
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import oauth.signpost.OAuth;
+
+/**
+ * A multi-map of HTTP request parameters. Each key references a
+ * {@link SortedSet} of parameters collected from the request during message
+ * signing. Parameter values are sorted as per {@linkplain http
+ * ://oauth.net/core/1.0a/#anchor13}. Every key/value pair will be
+ * percent-encoded upon insertion. This class has special semantics tailored to
+ * being useful for message signing; it's not a general purpose collection class
+ * to handle request parameters.
+ * 
+ * @author Matthias Kaeppler
+ */
+@SuppressWarnings("serial")
+public class HttpParameters implements Map<String, SortedSet<String>>, Serializable {
+
+    private TreeMap<String, SortedSet<String>> wrappedMap = new TreeMap<String, SortedSet<String>>();
+
+    public SortedSet<String> put(String key, SortedSet<String> value) {
+        return wrappedMap.put(key, value);
+    }
+
+    public SortedSet<String> put(String key, SortedSet<String> values, boolean percentEncode) {
+        if (percentEncode) {
+            remove(key);
+            for (String v : values) {
+                put(key, v, true);
+            }
+            return get(key);
+        } else {
+            return wrappedMap.put(key, values);
+        }
+    }
+
+    /**
+     * Convenience method to add a single value for the parameter specified by
+     * 'key'.
+     * 
+     * @param key
+     *        the parameter name
+     * @param value
+     *        the parameter value
+     * @return the value
+     */
+    public String put(String key, String value) {
+        return put(key, value, false);
+    }
+
+    /**
+     * Convenience method to add a single value for the parameter specified by
+     * 'key'.
+     * 
+     * @param key
+     *        the parameter name
+     * @param value
+     *        the parameter value
+     * @param percentEncode
+     *        whether key and value should be percent encoded before being
+     *        inserted into the map
+     * @return the value
+     */
+    public String put(String key, String value, boolean percentEncode) {
+        SortedSet<String> values = wrappedMap.get(key);
+        if (values == null) {
+            values = new TreeSet<String>();
+            wrappedMap.put(percentEncode ? OAuth.percentEncode(key) : key, values);
+        }
+        if (value != null) {
+            value = percentEncode ? OAuth.percentEncode(value) : value;
+            values.add(value);
+        }
+
+        return value;
+    }
+
+    /**
+     * Convenience method to allow for storing null values. {@link #put} doesn't
+     * allow null values, because that would be ambiguous.
+     * 
+     * @param key
+     *        the parameter name
+     * @param nullString
+     *        can be anything, but probably... null?
+     * @return null
+     */
+    public String putNull(String key, String nullString) {
+        return put(key, nullString);
+    }
+
+    public void putAll(Map<? extends String, ? extends SortedSet<String>> m) {
+        wrappedMap.putAll(m);
+    }
+
+    public void putAll(Map<? extends String, ? extends SortedSet<String>> m, boolean percentEncode) {
+        if (percentEncode) {
+            for (String key : m.keySet()) {
+                put(key, m.get(key), true);
+            }
+        } else {
+            wrappedMap.putAll(m);
+        }
+    }
+
+    public void putAll(String[] keyValuePairs, boolean percentEncode) {
+        for (int i = 0; i < keyValuePairs.length - 1; i += 2) {
+            this.put(keyValuePairs[i], keyValuePairs[i + 1], percentEncode);
+        }
+    }
+
+    /**
+     * Convenience method to merge a Map<String, List<String>>.
+     * 
+     * @param m
+     *        the map
+     */
+    public void putMap(Map<String, List<String>> m) {
+        for (String key : m.keySet()) {
+            SortedSet<String> vals = get(key);
+            if (vals == null) {
+                vals = new TreeSet<String>();
+                put(key, vals);
+            }
+            vals.addAll(m.get(key));
+        }
+    }
+
+    public SortedSet<String> get(Object key) {
+        return wrappedMap.get(key);
+    }
+
+    /**
+     * Convenience method for {@link #getFirst(key, false)}.
+     * 
+     * @param key
+     *        the parameter name (must be percent encoded if it contains unsafe
+     *        characters!)
+     * @return the first value found for this parameter
+     */
+    public String getFirst(Object key) {
+        return getFirst(key, false);
+    }
+
+    /**
+     * Returns the first value from the set of all values for the given
+     * parameter name. If the key passed to this method contains special
+     * characters, you MUST first percent encode it using
+     * {@link OAuth#percentEncode(String)}, otherwise the lookup will fail
+     * (that's because upon storing values in this map, keys get
+     * percent-encoded).
+     * 
+     * @param key
+     *        the parameter name (must be percent encoded if it contains unsafe
+     *        characters!)
+     * @param percentDecode
+     *        whether the value being retrieved should be percent decoded
+     * @return the first value found for this parameter
+     */
+    public String getFirst(Object key, boolean percentDecode) {
+        SortedSet<String> values = wrappedMap.get(key);
+        if (values == null || values.isEmpty()) {
+            return null;
+        }
+        String value = values.first();
+        return percentDecode ? OAuth.percentDecode(value) : value;
+    }
+
+    /**
+     * Concatenates all values for the given key to a list of key/value pairs
+     * suitable for use in a URL query string.
+     * 
+     * @param key
+     *        the parameter name
+     * @return the query string
+     */
+    public String getAsQueryString(Object key) {
+        StringBuilder sb = new StringBuilder();
+        key = OAuth.percentEncode((String) key);
+        Set<String> values = wrappedMap.get(key);
+        if (values == null) {
+            return key + "=";
+        }
+        Iterator<String> iter = values.iterator();
+        while (iter.hasNext()) {
+            sb.append(key + "=" + iter.next());
+            if (iter.hasNext()) {
+                sb.append("&");
+            }
+        }
+        return sb.toString();
+    }
+
+    public String getAsHeaderElement(String key) {
+        String value = getFirst(key);
+        if (value == null) {
+            return null;
+        }
+        return key + "=\"" + value + "\"";
+    }
+
+    public boolean containsKey(Object key) {
+        return wrappedMap.containsKey(key);
+    }
+
+    public boolean containsValue(Object value) {
+        for (Set<String> values : wrappedMap.values()) {
+            if (values.contains(value)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public int size() {
+        int count = 0;
+        for (String key : wrappedMap.keySet()) {
+            count += wrappedMap.get(key).size();
+        }
+        return count;
+    }
+
+    public boolean isEmpty() {
+        return wrappedMap.isEmpty();
+    }
+
+    public void clear() {
+        wrappedMap.clear();
+    }
+
+    public SortedSet<String> remove(Object key) {
+        return wrappedMap.remove(key);
+    }
+
+    public Set<String> keySet() {
+        return wrappedMap.keySet();
+    }
+
+    public Collection<SortedSet<String>> values() {
+        return wrappedMap.values();
+    }
+
+    public Set<java.util.Map.Entry<String, SortedSet<String>>> entrySet() {
+        return wrappedMap.entrySet();
+    }
+}
Index: /trunk/src/oauth/signpost/http/HttpRequest.java
===================================================================
--- /trunk/src/oauth/signpost/http/HttpRequest.java	(revision 4231)
+++ /trunk/src/oauth/signpost/http/HttpRequest.java	(revision 4231)
@@ -0,0 +1,44 @@
+package oauth.signpost.http;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+
+import oauth.signpost.OAuthConsumer;
+import oauth.signpost.basic.HttpURLConnectionRequestAdapter;
+
+/**
+ * A concise description of an HTTP request. Contains methods to access all
+ * those parts of an HTTP request which Signpost needs to sign a message. If you
+ * want to extend Signpost to sign a different kind of HTTP request than those
+ * currently supported, you'll have to write an adapter which implements this
+ * interface and a custom {@link OAuthConsumer} which performs the wrapping.
+ * 
+ * @see HttpURLConnectionRequestAdapter
+ * @author Matthias Kaeppler
+ */
+public interface HttpRequest {
+
+    String getMethod();
+
+    String getRequestUrl();
+
+    void setRequestUrl(String url);
+
+    void setHeader(String name, String value);
+
+    String getHeader(String name);
+
+    Map<String, String> getAllHeaders();
+
+    InputStream getMessagePayload() throws IOException;
+
+    String getContentType();
+
+    /**
+     * Returns the wrapped request object, in case you must work directly on it.
+     * 
+     * @return the wrapped request object
+     */
+    Object unwrap();
+}
Index: /trunk/src/oauth/signpost/http/HttpResponse.java
===================================================================
--- /trunk/src/oauth/signpost/http/HttpResponse.java	(revision 4231)
+++ /trunk/src/oauth/signpost/http/HttpResponse.java	(revision 4231)
@@ -0,0 +1,21 @@
+package oauth.signpost.http;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+public interface HttpResponse {
+
+    int getStatusCode() throws IOException;
+
+    String getReasonPhrase() throws Exception;
+
+    InputStream getContent() throws IOException;
+
+    /**
+     * Returns the underlying response object, in case you need to work on it
+     * directly.
+     * 
+     * @return the wrapped response object
+     */
+    Object unwrap();
+}
Index: /trunk/src/oauth/signpost/signature/AuthorizationHeaderSigningStrategy.java
===================================================================
--- /trunk/src/oauth/signpost/signature/AuthorizationHeaderSigningStrategy.java	(revision 4231)
+++ /trunk/src/oauth/signpost/signature/AuthorizationHeaderSigningStrategy.java	(revision 4231)
@@ -0,0 +1,55 @@
+package oauth.signpost.signature;
+
+import oauth.signpost.OAuth;
+import oauth.signpost.http.HttpParameters;
+import oauth.signpost.http.HttpRequest;
+
+/**
+ * Writes to the HTTP Authorization header field.
+ * 
+ * @author Matthias Kaeppler
+ */
+public class AuthorizationHeaderSigningStrategy implements SigningStrategy {
+
+    private static final long serialVersionUID = 1L;
+
+    public String writeSignature(String signature, HttpRequest request,
+            HttpParameters requestParameters) {
+        StringBuilder sb = new StringBuilder();
+
+        sb.append("OAuth ");
+        if (requestParameters.containsKey("realm")) {
+            sb.append(requestParameters.getAsHeaderElement("realm"));
+            sb.append(", ");
+        }
+        if (requestParameters.containsKey(OAuth.OAUTH_TOKEN)) {
+            sb.append(requestParameters.getAsHeaderElement(OAuth.OAUTH_TOKEN));
+            sb.append(", ");
+        }
+        if (requestParameters.containsKey(OAuth.OAUTH_CALLBACK)) {
+            sb.append(requestParameters.getAsHeaderElement(OAuth.OAUTH_CALLBACK));
+            sb.append(", ");
+        }
+        if (requestParameters.containsKey(OAuth.OAUTH_VERIFIER)) {
+            sb.append(requestParameters.getAsHeaderElement(OAuth.OAUTH_VERIFIER));
+            sb.append(", ");
+        }
+        sb.append(requestParameters.getAsHeaderElement(OAuth.OAUTH_CONSUMER_KEY));
+        sb.append(", ");
+        sb.append(requestParameters.getAsHeaderElement(OAuth.OAUTH_VERSION));
+        sb.append(", ");
+        sb.append(requestParameters.getAsHeaderElement(OAuth.OAUTH_SIGNATURE_METHOD));
+        sb.append(", ");
+        sb.append(requestParameters.getAsHeaderElement(OAuth.OAUTH_TIMESTAMP));
+        sb.append(", ");
+        sb.append(requestParameters.getAsHeaderElement(OAuth.OAUTH_NONCE));
+        sb.append(", ");
+        sb.append(OAuth.toHeaderElement(OAuth.OAUTH_SIGNATURE, signature));
+
+        String header = sb.toString();
+        request.setHeader(OAuth.HTTP_AUTHORIZATION_HEADER, header);
+
+        return header;
+    }
+
+}
Index: /trunk/src/oauth/signpost/signature/HmacSha1MessageSigner.java
===================================================================
--- /trunk/src/oauth/signpost/signature/HmacSha1MessageSigner.java	(revision 4231)
+++ /trunk/src/oauth/signpost/signature/HmacSha1MessageSigner.java	(revision 4231)
@@ -0,0 +1,62 @@
+/* Copyright (c) 2009 Matthias Kaeppler
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package oauth.signpost.signature;
+
+import java.io.UnsupportedEncodingException;
+import java.security.GeneralSecurityException;
+
+import javax.crypto.Mac;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+import oauth.signpost.OAuth;
+import oauth.signpost.exception.OAuthMessageSignerException;
+import oauth.signpost.http.HttpRequest;
+import oauth.signpost.http.HttpParameters;
+
+@SuppressWarnings("serial")
+public class HmacSha1MessageSigner extends OAuthMessageSigner {
+
+	private static final String MAC_NAME = "HmacSHA1";
+
+	@Override
+    public String getSignatureMethod() {
+        return "HMAC-SHA1";
+    }
+
+    @Override
+    public String sign(HttpRequest request, HttpParameters requestParams)
+            throws OAuthMessageSignerException {
+        try {
+            String keyString = OAuth.percentEncode(getConsumerSecret()) + '&'
+                    + OAuth.percentEncode(getTokenSecret());
+            byte[] keyBytes = keyString.getBytes(OAuth.ENCODING);
+
+            SecretKey key = new SecretKeySpec(keyBytes, MAC_NAME);
+            Mac mac = Mac.getInstance(MAC_NAME);
+            mac.init(key);
+
+            String sbs = new SignatureBaseString(request, requestParams).generate();
+            OAuth.debugOut("SBS", sbs);
+            byte[] text = sbs.getBytes(OAuth.ENCODING);
+
+            return base64Encode(mac.doFinal(text)).trim();
+        } catch (GeneralSecurityException e) {
+            throw new OAuthMessageSignerException(e);
+        } catch (UnsupportedEncodingException e) {
+            throw new OAuthMessageSignerException(e);
+        }
+    }
+}
Index: /trunk/src/oauth/signpost/signature/OAuthMessageSigner.java
===================================================================
--- /trunk/src/oauth/signpost/signature/OAuthMessageSigner.java	(revision 4231)
+++ /trunk/src/oauth/signpost/signature/OAuthMessageSigner.java	(revision 4231)
@@ -0,0 +1,74 @@
+/* Copyright (c) 2009 Matthias Kaeppler
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package oauth.signpost.signature;
+
+import java.io.IOException;
+import java.io.Serializable;
+
+import oauth.signpost.exception.OAuthMessageSignerException;
+import oauth.signpost.http.HttpRequest;
+import oauth.signpost.http.HttpParameters;
+
+import org.apache.commons.codec.binary.Base64;
+
+public abstract class OAuthMessageSigner implements Serializable {
+
+    private static final long serialVersionUID = 4445779788786131202L;
+
+    private transient Base64 base64;
+
+    private String consumerSecret;
+
+    private String tokenSecret;
+
+    public OAuthMessageSigner() {
+        this.base64 = new Base64();
+    }
+
+    public abstract String sign(HttpRequest request, HttpParameters requestParameters)
+            throws OAuthMessageSignerException;
+
+    public abstract String getSignatureMethod();
+
+    public String getConsumerSecret() {
+        return consumerSecret;
+    }
+
+    public String getTokenSecret() {
+        return tokenSecret;
+    }
+
+    public void setConsumerSecret(String consumerSecret) {
+        this.consumerSecret = consumerSecret;
+    }
+
+    public void setTokenSecret(String tokenSecret) {
+        this.tokenSecret = tokenSecret;
+    }
+
+    protected byte[] decodeBase64(String s) {
+        return base64.decode(s.getBytes());
+    }
+
+    protected String base64Encode(byte[] b) {
+        return new String(base64.encode(b));
+    }
+
+    private void readObject(java.io.ObjectInputStream stream)
+            throws IOException, ClassNotFoundException {
+        stream.defaultReadObject();
+        this.base64 = new Base64();
+    }
+}
Index: /trunk/src/oauth/signpost/signature/PlainTextMessageSigner.java
===================================================================
--- /trunk/src/oauth/signpost/signature/PlainTextMessageSigner.java	(revision 4231)
+++ /trunk/src/oauth/signpost/signature/PlainTextMessageSigner.java	(revision 4231)
@@ -0,0 +1,36 @@
+/* Copyright (c) 2009 Matthias Kaeppler
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package oauth.signpost.signature;
+
+import oauth.signpost.OAuth;
+import oauth.signpost.exception.OAuthMessageSignerException;
+import oauth.signpost.http.HttpRequest;
+import oauth.signpost.http.HttpParameters;
+
+@SuppressWarnings("serial")
+public class PlainTextMessageSigner extends OAuthMessageSigner {
+
+    @Override
+    public String getSignatureMethod() {
+        return "PLAINTEXT";
+    }
+
+    @Override
+    public String sign(HttpRequest request, HttpParameters requestParams)
+            throws OAuthMessageSignerException {
+        return OAuth.percentEncode(getConsumerSecret()) + '&'
+                + OAuth.percentEncode(getTokenSecret());
+    }
+}
Index: /trunk/src/oauth/signpost/signature/QueryStringSigningStrategy.java
===================================================================
--- /trunk/src/oauth/signpost/signature/QueryStringSigningStrategy.java	(revision 4231)
+++ /trunk/src/oauth/signpost/signature/QueryStringSigningStrategy.java	(revision 4231)
@@ -0,0 +1,60 @@
+package oauth.signpost.signature;
+
+import oauth.signpost.OAuth;
+import oauth.signpost.http.HttpParameters;
+import oauth.signpost.http.HttpRequest;
+
+/**
+ * Writes to a URL query string. <strong>Note that this currently ONLY works
+ * when signing a URL directly, not with HTTP request objects.</strong> That's
+ * because most HTTP request implementations do not allow the client to change
+ * the URL once the request has been instantiated, so there is no way to append
+ * parameters to it.
+ * 
+ * @author Matthias Kaeppler
+ */
+public class QueryStringSigningStrategy implements SigningStrategy {
+
+    private static final long serialVersionUID = 1L;
+
+    public String writeSignature(String signature, HttpRequest request,
+            HttpParameters requestParameters) {
+
+        // add the signature
+        StringBuilder sb = new StringBuilder(OAuth.addQueryParameters(request.getRequestUrl(),
+            OAuth.OAUTH_SIGNATURE, signature));
+
+        // add the optional OAuth parameters
+        if (requestParameters.containsKey(OAuth.OAUTH_TOKEN)) {
+            sb.append("&");
+            sb.append(requestParameters.getAsQueryString(OAuth.OAUTH_TOKEN));
+        }
+        if (requestParameters.containsKey(OAuth.OAUTH_CALLBACK)) {
+            sb.append("&");
+            sb.append(requestParameters.getAsQueryString(OAuth.OAUTH_CALLBACK));
+        }
+        if (requestParameters.containsKey(OAuth.OAUTH_VERIFIER)) {
+            sb.append("&");
+            sb.append(requestParameters.getAsQueryString(OAuth.OAUTH_VERIFIER));
+        }
+
+        // add the remaining OAuth params
+        sb.append("&");
+        sb.append(requestParameters.getAsQueryString(OAuth.OAUTH_CONSUMER_KEY));
+        sb.append("&");
+        sb.append(requestParameters.getAsQueryString(OAuth.OAUTH_VERSION));
+        sb.append("&");
+        sb.append(requestParameters.getAsQueryString(OAuth.OAUTH_SIGNATURE_METHOD));
+        sb.append("&");
+        sb.append(requestParameters.getAsQueryString(OAuth.OAUTH_TIMESTAMP));
+        sb.append("&");
+        sb.append(requestParameters.getAsQueryString(OAuth.OAUTH_NONCE));
+
+        String signedUrl = sb.toString();
+
+        request.setRequestUrl(signedUrl);
+
+        return signedUrl;
+    }
+
+}
Index: /trunk/src/oauth/signpost/signature/SignatureBaseString.java
===================================================================
--- /trunk/src/oauth/signpost/signature/SignatureBaseString.java	(revision 4231)
+++ /trunk/src/oauth/signpost/signature/SignatureBaseString.java	(revision 4231)
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2009 Matthias Kaeppler Licensed under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law
+ * or agreed to in writing, software distributed under the License is
+ * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+package oauth.signpost.signature;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Iterator;
+
+import oauth.signpost.OAuth;
+import oauth.signpost.exception.OAuthMessageSignerException;
+import oauth.signpost.http.HttpRequest;
+import oauth.signpost.http.HttpParameters;
+
+public class SignatureBaseString {
+
+    private HttpRequest request;
+
+    private HttpParameters requestParameters;
+
+    /**
+     * Constructs a new SBS instance that will operate on the given request
+     * object and parameter set.
+     * 
+     * @param request
+     *        the HTTP request
+     * @param requestParameters
+     *        the set of request parameters from the Authorization header, query
+     *        string and form body
+     */
+    public SignatureBaseString(HttpRequest request, HttpParameters requestParameters) {
+        this.request = request;
+        this.requestParameters = requestParameters;
+    }
+
+    /**
+     * Builds the signature base string from the data this instance was
+     * configured with.
+     * 
+     * @return the signature base string
+     * @throws OAuthMessageSignerException
+     */
+    public String generate() throws OAuthMessageSignerException {
+
+        try {
+            String normalizedUrl = normalizeRequestUrl();
+            String normalizedParams = normalizeRequestParameters();
+
+            return request.getMethod() + '&' + OAuth.percentEncode(normalizedUrl) + '&'
+                    + OAuth.percentEncode(normalizedParams);
+        } catch (Exception e) {
+            throw new OAuthMessageSignerException(e);
+        }
+    }
+
+    public String normalizeRequestUrl() throws URISyntaxException {
+        URI uri = new URI(request.getRequestUrl());
+        String scheme = uri.getScheme().toLowerCase();
+        String authority = uri.getAuthority().toLowerCase();
+        boolean dropPort = (scheme.equals("http") && uri.getPort() == 80)
+                || (scheme.equals("https") && uri.getPort() == 443);
+        if (dropPort) {
+            // find the last : in the authority
+            int index = authority.lastIndexOf(":");
+            if (index >= 0) {
+                authority = authority.substring(0, index);
+            }
+        }
+        String path = uri.getRawPath();
+        if (path == null || path.length() <= 0) {
+            path = "/"; // conforms to RFC 2616 section 3.2.2
+        }
+        // we know that there is no query and no fragment here.
+        return scheme + "://" + authority + path;
+    }
+
+    /**
+     * Normalizes the set of request parameters this instance was configured
+     * with, as per OAuth spec section 9.1.1.
+     * 
+     * @param parameters
+     *        the set of request parameters
+     * @return the normalized params string
+     * @throws IOException
+     */
+    public String normalizeRequestParameters() throws IOException {
+        if (requestParameters == null) {
+            return "";
+        }
+
+        StringBuilder sb = new StringBuilder();
+        Iterator<String> iter = requestParameters.keySet().iterator();
+
+        for (int i = 0; iter.hasNext(); i++) {
+            String param = iter.next();
+
+            if (OAuth.OAUTH_SIGNATURE.equals(param) || "realm".equals(param)) {
+                continue;
+            }
+
+            if (i > 0) {
+                sb.append("&");
+            }
+
+            sb.append(requestParameters.getAsQueryString(param));
+        }
+        return sb.toString();
+    }
+}
Index: /trunk/src/oauth/signpost/signature/SigningStrategy.java
===================================================================
--- /trunk/src/oauth/signpost/signature/SigningStrategy.java	(revision 4231)
+++ /trunk/src/oauth/signpost/signature/SigningStrategy.java	(revision 4231)
@@ -0,0 +1,37 @@
+package oauth.signpost.signature;
+
+import java.io.Serializable;
+
+import oauth.signpost.http.HttpRequest;
+import oauth.signpost.http.HttpParameters;
+
+/**
+ * <p>
+ * Defines how an OAuth signature string is written to a request.
+ * </p>
+ * <p>
+ * Unlike {@link OAuthMessageSigner}, which is concerned with <i>how</i> to
+ * generate a signature, this class is concered with <i>where</i> to write it
+ * (e.g. HTTP header or query string).
+ * </p>
+ * 
+ * @author Matthias Kaeppler
+ */
+public interface SigningStrategy extends Serializable {
+
+    /**
+     * Writes an OAuth signature and all remaining required parameters to an
+     * HTTP message.
+     * 
+     * @param signature
+     *        the signature to write
+     * @param request
+     *        the request to sign
+     * @param requestParameters
+     *        the request parameters
+     * @return whatever has been written to the request, e.g. an Authorization
+     *         header field
+     */
+    String writeSignature(String signature, HttpRequest request, HttpParameters requestParameters);
+    
+}
