Index: trunk/src/com/drew/lang/BufferBoundsException.java
===================================================================
--- trunk/src/com/drew/lang/BufferBoundsException.java	(revision 6127)
+++ trunk/src/com/drew/lang/BufferBoundsException.java	(revision 6127)
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2002-2012 Drew Noakes
+ *
+ *    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.
+ *
+ * More information about this project is available at:
+ *
+ *    http://drewnoakes.com/code/exif/
+ *    http://code.google.com/p/metadata-extractor/
+ */
+
+package com.drew.lang;
+
+import com.drew.lang.annotations.NotNull;
+
+import java.io.IOException;
+
+/**
+ * A checked replacement for IndexOutOfBoundsException.  Used by BufferReader.
+ * 
+ * @author Drew Noakes http://drewnoakes.com
+ */
+public final class BufferBoundsException extends Exception
+{
+    private static final long serialVersionUID = 2911102837808946396L;
+
+    public BufferBoundsException(@NotNull byte[] buffer, int index, int bytesRequested)
+    {
+        super(getMessage(buffer, index, bytesRequested));
+    }
+
+    public BufferBoundsException(final String message)
+    {
+        super(message);
+    }
+
+    public BufferBoundsException(final String message, final IOException innerException)
+    {
+        super(message, innerException);
+    }
+
+    private static String getMessage(@NotNull byte[] buffer, int index, int bytesRequested)
+    {
+        if (index < 0)
+            return String.format("Attempt to read from buffer using a negative index (%s)", index);
+
+        return String.format("Attempt to read %d byte%s from beyond end of buffer (requested index: %d, max index: %d)",
+                bytesRequested, bytesRequested==1?"":"s", index, buffer.length - 1);
+    }
+}
Index: trunk/src/com/drew/lang/BufferReader.java
===================================================================
--- trunk/src/com/drew/lang/BufferReader.java	(revision 6127)
+++ trunk/src/com/drew/lang/BufferReader.java	(revision 6127)
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2002-2012 Drew Noakes
+ *
+ *    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.
+ *
+ * More information about this project is available at:
+ *
+ *    http://drewnoakes.com/code/exif/
+ *    http://code.google.com/p/metadata-extractor/
+ */
+
+package com.drew.lang;
+
+import com.drew.lang.annotations.NotNull;
+
+public interface BufferReader
+{
+    /**
+     * Returns the length of the buffer.  This value represents the total number of bytes in the underlying source.
+     *
+     * @return The number of bytes in the buffer.
+     */
+    long getLength();
+
+    /**
+     * Sets the endianness of this reader.
+     * <ul>
+     * <li><code>true</code> for Motorola (or big) endianness</li>
+     * <li><code>false</code> for Intel (or little) endianness</li>
+     * </ul>
+     *
+     * @param motorolaByteOrder <code>true</code> for motorola/big endian, <code>false</code> for intel/little endian
+     */
+    void setMotorolaByteOrder(boolean motorolaByteOrder);
+
+    /**
+     * Gets the endianness of this reader.
+     * <ul>
+     * <li><code>true</code> for Motorola (or big) endianness</li>
+     * <li><code>false</code> for Intel (or little) endianness</li>
+     * </ul>
+     */
+    boolean isMotorolaByteOrder();
+
+    /**
+     * Returns an unsigned 8-bit int calculated from one byte of data at the specified index.
+     *
+     * @param index position within the data buffer to read byte
+     * @return the 8 bit int value, between 0 and 255
+     * @throws BufferBoundsException the buffer does not contain enough bytes to service the request, or index is negative
+     */
+    short getUInt8(int index) throws BufferBoundsException;
+
+    /**
+     * Returns a signed 8-bit int calculated from one byte of data at the specified index.
+     *
+     * @param index position within the data buffer to read byte
+     * @return the 8 bit int value, between 0x00 and 0xFF
+     * @throws BufferBoundsException the buffer does not contain enough bytes to service the request, or index is negative
+     */
+    byte getInt8(int index) throws BufferBoundsException;
+
+    /**
+     * Returns an unsigned 16-bit int calculated from two bytes of data at the specified index.
+     *
+     * @param index position within the data buffer to read first byte
+     * @return the 16 bit int value, between 0x0000 and 0xFFFF
+     * @throws BufferBoundsException the buffer does not contain enough bytes to service the request, or index is negative
+     */
+    int getUInt16(int index) throws BufferBoundsException;
+
+    /**
+     * Returns a signed 16-bit int calculated from two bytes of data at the specified index (MSB, LSB).
+     *
+     * @param index position within the data buffer to read first byte
+     * @return the 16 bit int value, between 0x0000 and 0xFFFF
+     * @throws BufferBoundsException the buffer does not contain enough bytes to service the request, or index is negative
+     */
+    short getInt16(int index) throws BufferBoundsException;
+
+    /**
+     * Get a 32-bit unsigned integer from the buffer, returning it as a long.
+     *
+     * @param index position within the data buffer to read first byte
+     * @return the unsigned 32-bit int value as a long, between 0x00000000 and 0xFFFFFFFF
+     * @throws BufferBoundsException the buffer does not contain enough bytes to service the request, or index is negative
+     */
+    long getUInt32(int index) throws BufferBoundsException;
+
+    /**
+     * Returns a signed 32-bit integer from four bytes of data at the specified index the buffer.
+     *
+     * @param index position within the data buffer to read first byte
+     * @return the signed 32 bit int value, between 0x00000000 and 0xFFFFFFFF
+     * @throws BufferBoundsException the buffer does not contain enough bytes to service the request, or index is negative
+     */
+    int getInt32(int index) throws BufferBoundsException;
+
+    /**
+     * Get a signed 64-bit integer from the buffer.
+     *
+     * @param index position within the data buffer to read first byte
+     * @return the 64 bit int value, between 0x0000000000000000 and 0xFFFFFFFFFFFFFFFF
+     * @throws BufferBoundsException the buffer does not contain enough bytes to service the request, or index is negative
+     */
+    long getInt64(int index) throws BufferBoundsException;
+
+    float getS15Fixed16(int index) throws BufferBoundsException;
+
+    float getFloat32(int index) throws BufferBoundsException;
+
+    double getDouble64(int index) throws BufferBoundsException;
+
+    @NotNull
+    byte[] getBytes(int index, int count) throws BufferBoundsException;
+
+    @NotNull
+    String getString(int index, int bytesRequested) throws BufferBoundsException;
+
+    @NotNull
+    String getString(int index, int bytesRequested, String charset) throws BufferBoundsException;
+
+    /**
+     * Creates a String from the _data buffer starting at the specified index,
+     * and ending where <code>byte=='\0'</code> or where <code>length==maxLength</code>.
+     *
+     * @param index          The index within the buffer at which to start reading the string.
+     * @param maxLengthBytes The maximum number of bytes to read.  If a zero-byte is not reached within this limit,
+     *                       reading will stop and the string will be truncated to this length.
+     * @return The read string.
+     * @throws BufferBoundsException The buffer does not contain enough bytes to satisfy this request.
+     */
+    @NotNull
+    String getNullTerminatedString(int index, int maxLengthBytes) throws BufferBoundsException;
+}
Index: trunk/src/com/drew/lang/ByteArrayReader.java
===================================================================
--- trunk/src/com/drew/lang/ByteArrayReader.java	(revision 6127)
+++ trunk/src/com/drew/lang/ByteArrayReader.java	(revision 6127)
@@ -0,0 +1,273 @@
+/*
+ * Copyright 2002-2012 Drew Noakes
+ *
+ *    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.
+ *
+ * More information about this project is available at:
+ *
+ *    http://drewnoakes.com/code/exif/
+ *    http://code.google.com/p/metadata-extractor/
+ */
+
+package com.drew.lang;
+
+import com.drew.lang.annotations.NotNull;
+
+import java.io.UnsupportedEncodingException;
+
+/**
+ * Provides methods to read specific values from a byte array, with a consistent, checked exception structure for
+ * issues.
+ * <p/>
+ * By default, the reader operates with Motorola byte order (big endianness).  This can be changed by calling
+ * {@see setMotorolaByteOrder(boolean)}.
+ * 
+ * @author Drew Noakes http://drewnoakes.com
+ * */
+public class ByteArrayReader implements BufferReader
+{
+    @NotNull
+    private final byte[] _buffer;
+    private boolean _isMotorolaByteOrder = true;
+
+    @SuppressWarnings({ "ConstantConditions" })
+    @com.drew.lang.annotations.SuppressWarnings(value = "EI_EXPOSE_REP2", justification = "Design intent")
+    public ByteArrayReader(@NotNull byte[] buffer)
+    {
+        if (buffer == null)
+            throw new NullPointerException();
+        
+        _buffer = buffer;
+    }
+
+    @Override
+    public long getLength()
+    {
+        return _buffer.length;
+    }
+
+
+    @Override
+    public void setMotorolaByteOrder(boolean motorolaByteOrder)
+    {
+        _isMotorolaByteOrder = motorolaByteOrder;
+    }
+
+    @Override
+    public boolean isMotorolaByteOrder()
+    {
+        return _isMotorolaByteOrder;
+    }
+
+    @Override
+    public short getUInt8(int index) throws BufferBoundsException
+    {
+        checkBounds(index, 1);
+
+        return (short) (_buffer[index] & 255);
+    }
+
+    @Override
+    public byte getInt8(int index) throws BufferBoundsException
+    {
+        checkBounds(index, 1);
+
+        return _buffer[index];
+    }
+
+    @Override
+    public int getUInt16(int index) throws BufferBoundsException
+    {
+        checkBounds(index, 2);
+
+        if (_isMotorolaByteOrder) {
+            // Motorola - MSB first
+            return (_buffer[index    ] << 8 & 0xFF00) |
+                   (_buffer[index + 1]      & 0xFF);
+        } else {
+            // Intel ordering - LSB first
+            return (_buffer[index + 1] << 8 & 0xFF00) |
+                   (_buffer[index    ]      & 0xFF);
+        }
+    }
+
+    @Override
+    public short getInt16(int index) throws BufferBoundsException
+    {
+        checkBounds(index, 2);
+
+        if (_isMotorolaByteOrder) {
+            // Motorola - MSB first
+            return (short) (((short)_buffer[index    ] << 8 & (short)0xFF00) |
+                            ((short)_buffer[index + 1]      & (short)0xFF));
+        } else {
+            // Intel ordering - LSB first
+            return (short) (((short)_buffer[index + 1] << 8 & (short)0xFF00) |
+                            ((short)_buffer[index    ]      & (short)0xFF));
+        }
+    }
+
+    @Override
+    public long getUInt32(int index) throws BufferBoundsException
+    {
+        checkBounds(index, 4);
+
+        if (_isMotorolaByteOrder) {
+            // Motorola - MSB first (big endian)
+            return (((long)_buffer[index    ]) << 24 & 0xFF000000L) |
+                    (((long)_buffer[index + 1]) << 16 & 0xFF0000L) |
+                    (((long)_buffer[index + 2]) << 8  & 0xFF00L) |
+                    (((long)_buffer[index + 3])       & 0xFFL);
+        } else {
+            // Intel ordering - LSB first (little endian)
+            return (((long)_buffer[index + 3]) << 24 & 0xFF000000L) |
+                    (((long)_buffer[index + 2]) << 16 & 0xFF0000L) |
+                    (((long)_buffer[index + 1]) << 8  & 0xFF00L) |
+                    (((long)_buffer[index    ])       & 0xFFL);
+        }
+    }
+
+    @Override
+    public int getInt32(int index) throws BufferBoundsException
+    {
+        checkBounds(index, 4);
+
+        if (_isMotorolaByteOrder) {
+            // Motorola - MSB first (big endian)
+            return (_buffer[index    ] << 24 & 0xFF000000) |
+                   (_buffer[index + 1] << 16 & 0xFF0000) |
+                   (_buffer[index + 2] << 8  & 0xFF00) |
+                   (_buffer[index + 3]       & 0xFF);
+        } else {
+            // Intel ordering - LSB first (little endian)
+            return (_buffer[index + 3] << 24 & 0xFF000000) |
+                   (_buffer[index + 2] << 16 & 0xFF0000) |
+                   (_buffer[index + 1] << 8  & 0xFF00) |
+                   (_buffer[index    ]       & 0xFF);
+        }
+    }
+
+    @Override
+    public long getInt64(int index) throws BufferBoundsException
+    {
+        checkBounds(index, 8);
+
+        if (_isMotorolaByteOrder) {
+            // Motorola - MSB first
+            return ((long)_buffer[index    ] << 56 & 0xFF00000000000000L) |
+                   ((long)_buffer[index + 1] << 48 & 0xFF000000000000L) |
+                   ((long)_buffer[index + 2] << 40 & 0xFF0000000000L) |
+                   ((long)_buffer[index + 3] << 32 & 0xFF00000000L) |
+                   ((long)_buffer[index + 4] << 24 & 0xFF000000L) |
+                   ((long)_buffer[index + 5] << 16 & 0xFF0000L) |
+                   ((long)_buffer[index + 6] << 8  & 0xFF00L) |
+                   ((long)_buffer[index + 7]       & 0xFFL);
+        } else {
+            // Intel ordering - LSB first
+            return ((long)_buffer[index + 7] << 56 & 0xFF00000000000000L) |
+                   ((long)_buffer[index + 6] << 48 & 0xFF000000000000L) |
+                   ((long)_buffer[index + 5] << 40 & 0xFF0000000000L) |
+                   ((long)_buffer[index + 4] << 32 & 0xFF00000000L) |
+                   ((long)_buffer[index + 3] << 24 & 0xFF000000L) |
+                   ((long)_buffer[index + 2] << 16 & 0xFF0000L) |
+                   ((long)_buffer[index + 1] << 8  & 0xFF00L) |
+                   ((long)_buffer[index    ]       & 0xFFL);
+        }
+    }
+
+    @Override
+    public float getS15Fixed16(int index) throws BufferBoundsException
+    {
+        checkBounds(index, 4);
+
+        if (_isMotorolaByteOrder) {
+            float res = (_buffer[index    ] & 255) << 8 |
+                        (_buffer[index + 1] & 255);
+            int d =     (_buffer[index + 2] & 255) << 8 |
+                        (_buffer[index + 3] & 255);
+            return (float)(res + d/65536.0);
+        } else {
+            // this particular branch is untested
+            float res = (_buffer[index + 3] & 255) << 8 |
+                        (_buffer[index + 2] & 255);
+            int d =     (_buffer[index + 1] & 255) << 8 |
+                        (_buffer[index    ] & 255);
+            return (float)(res + d/65536.0);
+        }
+    }
+
+    @Override
+    public float getFloat32(int index) throws BufferBoundsException
+    {
+        return Float.intBitsToFloat(getInt32(index));
+    }
+
+    @Override
+    public double getDouble64(int index) throws BufferBoundsException
+    {
+        return Double.longBitsToDouble(getInt64(index));
+    }
+    
+    @Override
+    @NotNull
+    public byte[] getBytes(int index, int count) throws BufferBoundsException
+    {
+        checkBounds(index, count);
+
+        byte[] bytes = new byte[count];
+        System.arraycopy(_buffer, index, bytes, 0, count);
+        return bytes;
+    }
+
+    @Override
+    @NotNull
+    public String getString(int index, int bytesRequested) throws BufferBoundsException
+    {
+        return new String(getBytes(index, bytesRequested));
+    }
+
+    @Override
+    @NotNull
+    public String getString(int index, int bytesRequested, String charset) throws BufferBoundsException
+    {
+        byte[] bytes = getBytes(index, bytesRequested);
+        try {
+            return new String(bytes, charset);
+        } catch (UnsupportedEncodingException e) {
+            return new String(bytes);
+        }
+    }
+
+    @Override
+    @NotNull
+    public String getNullTerminatedString(int index, int maxLengthBytes) throws BufferBoundsException
+    {
+        // NOTE currently only really suited to single-byte character strings
+
+        checkBounds(index, maxLengthBytes);
+
+        // Check for null terminators
+        int length = 0;
+        while ((index + length) < _buffer.length && _buffer[index + length] != '\0' && length < maxLengthBytes)
+            length++;
+
+        byte[] bytes = getBytes(index, length);
+        return new String(bytes);
+    }
+
+    private void checkBounds(final int index, final int bytesRequested) throws BufferBoundsException
+    {
+        if (bytesRequested < 0 || index < 0 || (long)index + (long)bytesRequested - 1L >= (long)_buffer.length)
+            throw new BufferBoundsException(_buffer, index, bytesRequested);
+    }
+}
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 6127)
@@ -1,17 +1,26 @@
 /*
- * 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.
+ * Copyright 2002-2012 Drew Noakes
  *
- * 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.
+ *    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
  *
- * 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/
+ *        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.
+ *
+ * More information about this project is available at:
+ *
+ *    http://drewnoakes.com/code/exif/
+ *    http://code.google.com/p/metadata-extractor/
  */
 package com.drew.lang;
+
+import com.drew.lang.annotations.NotNull;
+import com.drew.lang.annotations.Nullable;
 
 import java.io.PrintStream;
@@ -22,58 +31,65 @@
  * unavailable in previous versions.  This class allows support
  * of these previous JDK versions.
+ *
+ * @author Drew Noakes http://drewnoakes.com
  */
 public class CompoundException extends Exception
 {
-    private final Throwable _innnerException;
+    private static final long serialVersionUID = -9207883813472069925L;
 
-    public CompoundException(String msg)
+    @Nullable
+    private final Throwable _innerException;
+
+    public CompoundException(@Nullable String msg)
     {
         this(msg, null);
     }
 
-    public CompoundException(Throwable exception)
+    public CompoundException(@Nullable Throwable exception)
     {
         this(null, exception);
     }
 
-    public CompoundException(String msg, Throwable innerException)
+    public CompoundException(@Nullable String msg, @Nullable Throwable innerException)
     {
         super(msg);
-        _innnerException = innerException;
+        _innerException = innerException;
     }
 
+    @Nullable
     public Throwable getInnerException()
     {
-        return _innnerException;
+        return _innerException;
     }
 
+    @NotNull
     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());
+        StringBuilder string = new StringBuilder();
+        string.append(super.toString());
+        if (_innerException != null) {
+            string.append("\n");
+            string.append("--- inner exception ---");
+            string.append("\n");
+            string.append(_innerException.toString());
         }
-        return sbuffer.toString();
+        return string.toString();
     }
 
-    public void printStackTrace(PrintStream s)
+    public void printStackTrace(@NotNull PrintStream s)
     {
         super.printStackTrace(s);
-        if (_innnerException != null) {
+        if (_innerException != null) {
             s.println("--- inner exception ---");
-            _innnerException.printStackTrace(s);
+            _innerException.printStackTrace(s);
         }
     }
 
-    public void printStackTrace(PrintWriter s)
+    public void printStackTrace(@NotNull PrintWriter s)
     {
         super.printStackTrace(s);
-        if (_innnerException != null) {
+        if (_innerException != null) {
             s.println("--- inner exception ---");
-            _innnerException.printStackTrace(s);
+            _innerException.printStackTrace(s);
         }
     }
@@ -82,7 +98,7 @@
     {
         super.printStackTrace();
-        if (_innnerException != null) {
+        if (_innerException != null) {
             System.err.println("--- inner exception ---");
-            _innnerException.printStackTrace();
+            _innerException.printStackTrace();
         }
     }
Index: trunk/src/com/drew/lang/GeoLocation.java
===================================================================
--- trunk/src/com/drew/lang/GeoLocation.java	(revision 6127)
+++ trunk/src/com/drew/lang/GeoLocation.java	(revision 6127)
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2002-2012 Drew Noakes
+ *
+ *    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.
+ *
+ * More information about this project is available at:
+ *
+ *    http://drewnoakes.com/code/exif/
+ *    http://code.google.com/p/metadata-extractor/
+ */
+
+package com.drew.lang;
+
+import com.drew.lang.annotations.NotNull;
+import com.drew.lang.annotations.Nullable;
+
+/**
+ * Represents a latitude and longitude pair, giving a position on earth in spherical coordinates.
+ * Values of latitude and longitude are given in degrees.
+ * This type is immutable.
+ */
+public final class GeoLocation
+{
+    private final double _latitude;
+    private final double _longitude;
+
+    /**
+     * Instantiates a new instance of {@link GeoLocation}.
+     *
+     * @param latitude the latitude, in degrees
+     * @param longitude the longitude, in degrees
+     */
+    public GeoLocation(double latitude, double longitude)
+    {
+        _latitude = latitude;
+        _longitude = longitude;
+    }
+
+    /**
+     * @return the latitudinal angle of this location, in degrees.
+     */
+    public double getLatitude()
+    {
+        return _latitude;
+    }
+
+    /**
+     * @return the longitudinal angle of this location, in degrees.
+     */
+    public double getLongitude()
+    {
+        return _longitude;
+    }
+
+    /**
+     * @return true, if both latitude and longitude are equal to zero
+     */
+    public boolean isZero()
+    {
+        return _latitude == 0 && _longitude == 0;
+    }
+
+    /**
+     * Converts a decimal degree angle into its corresponding DMS (degrees-minutes-seconds) representation as a string,
+     * of format: {@code -1° 23' 4.56"}
+     */
+    @NotNull
+    public static String decimalToDegreesMinutesSecondsString(double decimal)
+    {
+        double[] dms = decimalToDegreesMinutesSeconds(decimal);
+        return dms[0] + "° " + dms[1] + "' " + dms[2] + '"';
+    }
+
+    /**
+     * Converts a decimal degree angle into its corresponding DMS (degrees-minutes-seconds) component values, as
+     * a double array.
+     */
+    @NotNull
+    public static double[] decimalToDegreesMinutesSeconds(double decimal)
+    {
+        int d = (int)decimal;
+        double m = Math.abs((decimal % 1) * 60);
+        double s = (m % 1) * 60;
+        return new double[] { d, (int)m, s};
+    }
+
+    /**
+     * Converts DMS (degrees-minutes-seconds) rational values, as given in {@link com.drew.metadata.exif.GpsDirectory},
+     * into a single value in degrees, as a double.
+     */
+    @Nullable
+    public static Double degreesMinutesSecondsToDecimal(@NotNull final Rational degs, @NotNull final Rational mins, @NotNull final Rational secs, final boolean isNegative)
+    {
+        double decimal = Math.abs(degs.doubleValue())
+                + mins.doubleValue() / 60.0d
+                + secs.doubleValue() / 3600.0d;
+
+        if (Double.isNaN(decimal))
+            return null;
+
+        if (isNegative)
+            decimal *= -1;
+
+        return decimal;
+    }
+
+    @Override
+    public boolean equals(final Object o)
+    {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        GeoLocation that = (GeoLocation) o;
+        if (Double.compare(that._latitude, _latitude) != 0) return false;
+        if (Double.compare(that._longitude, _longitude) != 0) return false;
+        return true;
+    }
+
+    @Override
+    public int hashCode()
+    {
+        int result;
+        long temp;
+        temp = _latitude != +0.0d ? Double.doubleToLongBits(_latitude) : 0L;
+        result = (int) (temp ^ (temp >>> 32));
+        temp = _longitude != +0.0d ? Double.doubleToLongBits(_longitude) : 0L;
+        result = 31 * result + (int) (temp ^ (temp >>> 32));
+        return result;
+    }
+
+    /**
+     * @return a string representation of this location, of format: {@code 1.23, 4.56}
+     */
+    @Override
+    @NotNull
+    public String toString()
+    {
+        return _latitude + ", " + _longitude;
+    }
+
+    /**
+     * @return a string representation of this location, of format: {@code -1° 23' 4.56", 54° 32' 1.92"}
+     */
+    @NotNull
+    public String toDMSString()
+    {
+        return decimalToDegreesMinutesSecondsString(_latitude) + ", " + decimalToDegreesMinutesSecondsString(_longitude);
+    }
+}
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 6127)
@@ -1,17 +1,21 @@
-/**
- * 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.
+/*
+ * Copyright 2002-2012 Drew Noakes
  *
- * 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.
+ *    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
  *
- * 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/
+ *        http://www.apache.org/licenses/LICENSE-2.0
  *
- * Created by dnoakes on Dec 15, 2002 3:30:59 PM using IntelliJ IDEA.
+ *    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.
+ *
+ * More information about this project is available at:
+ *
+ *    http://drewnoakes.com/code/exif/
+ *    http://code.google.com/p/metadata-extractor/
  */
 package com.drew.lang;
@@ -20,4 +24,9 @@
 import java.io.OutputStream;
 
+/**
+ * An implementation of OutputSteam that ignores write requests by doing nothing.  This class may be useful in tests.
+ *
+ * @author Drew Noakes http://drewnoakes.com
+ */
 public class NullOutputStream extends OutputStream
 {
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 6127)
@@ -1,35 +1,27 @@
 /*
- * 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
+ * Copyright 2002-2012 Drew Noakes
+ *
+ *    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.
+ *
+ * More information about this project is available at:
+ *
+ *    http://drewnoakes.com/code/exif/
+ *    http://code.google.com/p/metadata-extractor/
  */
 
 package com.drew.lang;
+
+import com.drew.lang.annotations.NotNull;
+import com.drew.lang.annotations.Nullable;
 
 import java.io.Serializable;
@@ -38,20 +30,16 @@
  * 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
+ *
+ * @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;
+    private static final long serialVersionUID = 510688928138848770L;
+
+    /** Holds the numerator. */
+    private final long _numerator;
+
+    /** Holds the denominator. */
+    private final long _denominator;
 
     /**
@@ -60,8 +48,8 @@
      * with them!
      */
-    public Rational(int numerator, int denominator)
-    {
-        this.numerator = numerator;
-        this.denominator = denominator;
+    public Rational(long numerator, long denominator)
+    {
+        _numerator = numerator;
+        _denominator = denominator;
     }
 
@@ -70,10 +58,10 @@
      * This may involve rounding.
      *
-     * @return  the numeric value represented by this object after conversion
-     *          to type <code>double</code>.
+     * @return the numeric value represented by this object after conversion
+     *         to type <code>double</code>.
      */
     public double doubleValue()
     {
-        return (double)numerator / (double)denominator;
+        return (double) _numerator / (double) _denominator;
     }
 
@@ -82,10 +70,10 @@
      * This may involve rounding.
      *
-     * @return  the numeric value represented by this object after conversion
-     *          to type <code>float</code>.
+     * @return the numeric value represented by this object after conversion
+     *         to type <code>float</code>.
      */
     public float floatValue()
     {
-        return (float)numerator / (float)denominator;
+        return (float) _numerator / (float) _denominator;
     }
 
@@ -95,10 +83,10 @@
      * 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>.
+     * @return the numeric value represented by this object after conversion
+     *         to type <code>byte</code>.
      */
     public final byte byteValue()
     {
-        return (byte)doubleValue();
+        return (byte) doubleValue();
     }
 
@@ -108,10 +96,10 @@
      * 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>.
+     * @return the numeric value represented by this object after conversion
+     *         to type <code>int</code>.
      */
     public final int intValue()
     {
-        return (int)doubleValue();
+        return (int) doubleValue();
     }
 
@@ -121,10 +109,10 @@
      * 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>.
+     * @return the numeric value represented by this object after conversion
+     *         to type <code>long</code>.
      */
     public final long longValue()
     {
-        return (long)doubleValue();
+        return (long) doubleValue();
     }
 
@@ -134,74 +122,66 @@
      * 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>.
+     * @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 (short) doubleValue();
+    }
+
+
+    /** Returns the denominator. */
+    public final long getDenominator()
+    {
+        return this._denominator;
+    }
+
+    /** Returns the numerator. */
+    public final long getNumerator()
+    {
+        return this._numerator;
+    }
+
+    /**
+     * Returns the reciprocal value of this object as a new Rational.
+     *
      * @return the reciprocal in a new object
      */
+    @NotNull
     public Rational getReciprocal()
     {
-        return new Rational(this.denominator, this.numerator);
-    }
-
-    /**
-     * Checks if this rational number is an Integer, either positive or negative.
-     */
+        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;
-        }
+        return _denominator == 1 ||
+                (_denominator != 0 && (_numerator % _denominator == 0)) ||
+                (_denominator == 0 && _numerator == 0);
     }
 
     /**
      * Returns a string representation of the object of form <code>numerator/denominator</code>.
-     * @return  a string representation of the object.
-     */
+     *
+     * @return a string representation of the object.
+     */
+    @NotNull
     public String toString()
     {
-        return numerator + "/" + denominator;
-    }
-
-    /**
-     * Returns the simplest represenation of this Rational's value possible.
-     */
+        return _numerator + "/" + _denominator;
+    }
+
+    /** Returns the simplest representation of this Rational's value possible. */
+    @NotNull
     public String toSimpleString(boolean allowDecimal)
     {
-        if (denominator == 0 && numerator != 0) {
+        if (_denominator == 0 && _numerator != 0) {
             return toString();
         } else if (isInteger()) {
             return Integer.toString(intValue());
-        } else if (numerator != 1 && denominator % numerator == 0) {
+        } else if (_numerator != 1 && _denominator % _numerator == 0) {
             // common factor between denominator and numerator
-            int newDenominator = denominator / numerator;
+            long newDenominator = _denominator / _numerator;
             return new Rational(1, newDenominator).toSimpleString(allowDecimal);
         } else {
@@ -220,9 +200,11 @@
      * 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);
+        double maxPossibleCalculations = (((double) (Math.min(_denominator, _numerator) - 1) / 5d) + 2);
+        final int maxSimplificationCalculations = 1000;
         return maxPossibleCalculations > maxSimplificationCalculations;
     }
@@ -231,15 +213,22 @@
      * 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)) {
+    @Override
+    public boolean equals(@Nullable Object obj)
+    {
+        if (obj==null || !(obj instanceof Rational))
             return false;
-        }
-        Rational that = (Rational)obj;
+        Rational that = (Rational) obj;
         return this.doubleValue() == that.doubleValue();
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return (23 * (int)_denominator) + (int)_numerator;
     }
 
@@ -252,5 +241,5 @@
      * 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>
+     * the minimum number of checks required.</p>
      * <p>
      * However, generating the prime number series seems to be a hefty task.  Perhaps
@@ -265,12 +254,14 @@
      *   -- * ------------------------------------ + 2
      *   10                    2
-     *
+     * <p/>
      *   Math.min(denominator, numerator) - 1
      * = ------------------------------------ + 2
      *                  5
      * </pre></code>
-     * @return a simplified instance, or if the Rational could not be simpliffied,
+     *
+     * @return a simplified instance, or if the Rational could not be simplified,
      *         returns itself (unchanged)
      */
+    @NotNull
     public Rational getSimplifiedInstance()
     {
@@ -278,11 +269,11 @@
             return this;
         }
-        for (int factor = 2; factor <= Math.min(denominator, numerator); factor++) {
+        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) {
+            if (_denominator % factor == 0 && _numerator % factor == 0) {
                 // found a common factor
-                return new Rational(numerator / factor, denominator / factor);
+                return new Rational(_numerator / factor, _denominator / factor);
             }
         }
Index: trunk/src/com/drew/lang/StringUtil.java
===================================================================
--- trunk/src/com/drew/lang/StringUtil.java	(revision 6127)
+++ trunk/src/com/drew/lang/StringUtil.java	(revision 6127)
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2002-2012 Drew Noakes
+ *
+ *    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.
+ *
+ * More information about this project is available at:
+ *
+ *    http://drewnoakes.com/code/exif/
+ *    http://code.google.com/p/metadata-extractor/
+ */
+
+package com.drew.lang;
+
+import com.drew.lang.annotations.NotNull;
+
+import java.util.Iterator;
+
+/** @author Drew Noakes http://drewnoakes.com */
+public class StringUtil
+{
+    public static String join(@NotNull Iterable<? extends CharSequence> strings, @NotNull String delimiter)
+    {
+        int capacity = 0;
+        int delimLength = delimiter.length();
+
+        Iterator<? extends CharSequence> iter = strings.iterator();
+        if (iter.hasNext())
+            capacity += iter.next().length() + delimLength;
+
+        StringBuilder buffer = new StringBuilder(capacity);
+        iter = strings.iterator();
+        if (iter.hasNext()) {
+            buffer.append(iter.next());
+            while (iter.hasNext()) {
+                buffer.append(delimiter);
+                buffer.append(iter.next());
+            }
+        }
+        return buffer.toString();
+    }
+
+    public static <T extends CharSequence> String join(@NotNull T[] strings, @NotNull String delimiter)
+    {
+        int capacity = 0;
+        int delimLength = delimiter.length();
+        for (T value : strings)
+            capacity += value.length() + delimLength;
+
+        StringBuilder buffer = new StringBuilder(capacity);
+        boolean first = true;
+        for (T value : strings) {
+            if (!first) {
+                buffer.append(delimiter);
+            } else {
+                first = false;
+            }
+            buffer.append(value);
+        }
+        return buffer.toString();
+    }
+}
Index: trunk/src/com/drew/lang/annotations/NotNull.java
===================================================================
--- trunk/src/com/drew/lang/annotations/NotNull.java	(revision 6127)
+++ trunk/src/com/drew/lang/annotations/NotNull.java	(revision 6127)
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2002-2012 Drew Noakes
+ *
+ *    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.
+ *
+ * More information about this project is available at:
+ *
+ *    http://drewnoakes.com/code/exif/
+ *    http://code.google.com/p/metadata-extractor/
+ */
+
+package com.drew.lang.annotations;
+
+/**
+ * @author Drew Noakes http://drewnoakes.com
+ */
+public @interface NotNull
+{
+}
Index: trunk/src/com/drew/lang/annotations/Nullable.java
===================================================================
--- trunk/src/com/drew/lang/annotations/Nullable.java	(revision 6127)
+++ trunk/src/com/drew/lang/annotations/Nullable.java	(revision 6127)
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2002-2012 Drew Noakes
+ *
+ *    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.
+ *
+ * More information about this project is available at:
+ *
+ *    http://drewnoakes.com/code/exif/
+ *    http://code.google.com/p/metadata-extractor/
+ */
+
+package com.drew.lang.annotations;
+
+/**
+ * @author Drew Noakes http://drewnoakes.com
+ */
+public @interface Nullable
+{
+}
Index: trunk/src/com/drew/lang/annotations/SuppressWarnings.java
===================================================================
--- trunk/src/com/drew/lang/annotations/SuppressWarnings.java	(revision 6127)
+++ trunk/src/com/drew/lang/annotations/SuppressWarnings.java	(revision 6127)
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2002-2011 Andreas Ziermann
+ *
+ *    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.
+ *
+ * More information about this project is available at:
+ *
+ *    http://drewnoakes.com/code/exif/
+ *    http://code.google.com/p/metadata-extractor/
+ */
+
+package com.drew.lang.annotations;
+
+/**
+ * Used to suppress specific code analysis warnings produced by the Findbugs tool.
+ *
+ * @author Andreas Ziermann
+ */
+public @interface SuppressWarnings
+{
+    /**
+     * The name of the warning to be suppressed.
+     * @return The name of the warning to be suppressed.
+     */
+    @NotNull String value();
+
+    /**
+     * An explanation of why it is valid to suppress the warning in a particular situation/context.
+     * @return An explanation of why it is valid to suppress the warning in a particular situation/context.
+     */
+    @NotNull String justification();
+}
